Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

rolter documentation

Start here. rolter is a high-performance OpenAI/Anthropic-compatible AI gateway and load balancer (Rust data plane + control plane, shadcn/ui dashboard).

Architecture

Decisions

API

Development

Deployment

Planning

Architecture overview

rolter is split into a data plane (the hot proxy path) and a control plane (management, RBAC, config writes, UI host), sharing a set of Rust library crates. This separation lets the proxy stay lean and fast while the control plane handles CRUD, auth, and persistence.

Components

flowchart LR
  subgraph clients[Clients]
    C([OpenAI / Anthropic SDKs])
    A([Dashboard user])
  end
  C -->|/v1/*| GW["rolter-gateway<br/>data plane (Axum)"]
  A --> CTL["rolter-control<br/>control plane + UI host"]
  GW -->|forward + stream| UP["Upstreams<br/>OpenAI · Anthropic · vLLM pool"]
  CTL -->|read/write| PG[("PostgreSQL<br/>source of truth")]
  CTL -->|publish change events| RDS[("Redis / Valkey")]
  RDS -->|notify| GW
  GW -->|atomic snapshot swap| GW
  GW -->|async batched writes| CH[("ClickHouse<br/>logs + cost")]

Crates

  • rolter-core — config model, domain types, error type, telemetry init. No I/O.
  • rolter-balancer — LoadBalancer trait + strategies; pure and unit-tested.
  • rolter-proxy — Forwarder over pooled reqwest clients; header injection, model rewrite, streaming, per-provider egress proxy.
  • rolter-store — repository traits + in-memory impl; Postgres/Redis/ClickHouse backends land behind features.
  • rolter-auth — virtual-key verification, roles, access checks.
  • rolter-gateway (bin) — the data plane.
  • rolter-control (bin) — the control plane + static UI host.

Data plane (rolter-gateway)

Built on Axum/Hyper/Tower. Holds an ArcSwap<Snapshot> routing table; reads are lock-free. Per request it:

  1. parses the JSON body enough to read model and stream
  2. authenticates the virtual key (when keys are configured) and checks model access
  3. resolves the route, asks the route’s balancer to pick a target, then observes it (cache-aware learning)
  4. forwards to the upstream provider with the right auth header, rewriting the model id
  5. streams the response body straight back with minimal copying

See data plane details in performance.md.

Control plane (rolter-control)

Hosts the management REST API consumed by the dashboard and serves the built SPA as static assets (ServeDir). It owns all writes to Postgres, enforces RBAC, and publishes a config-change event to Redis so gateways hot-swap their snapshot without a restart (see config-and-hot-reload.md).

Datastores

  • PostgreSQL — source of truth for tenancy, RBAC, providers, routes, virtual keys, pricing, budgets.
  • Redis / Valkey — response cache, rate-limit counters, cooldown state, and pub/sub for config propagation.
  • ClickHouse — high-volume request and cost logs, queried by the dashboard for usage analytics.

Request lifecycle (sequence)

sequenceDiagram
  participant Client
  participant Gateway
  participant Balancer
  participant Upstream
  participant ClickHouse
  Client->>Gateway: POST /v1/chat/completions
  Gateway->>Gateway: auth virtual key + model access
  Gateway->>Balancer: pick(route, ctx)
  Balancer-->>Gateway: target index
  Gateway->>Upstream: forward (Bearer/x-api-key, rewritten model)
  Upstream-->>Gateway: response stream
  Gateway-->>Client: streamed body
  Gateway--)ClickHouse: async usage + cost log

Extensibility

  • New balancing strategy: implement rolter_balancer::LoadBalancer, wire into build().
  • New provider protocol: extend ProviderKind and rolter-proxy translation.
  • New storage backend: implement rolter-store traits behind a cargo feature.
  • New modality (audio/image/video): add endpoints + provider adapters; the balancer/auth/logging layers are modality-agnostic.

Load balancing

Each route maps a public model name to one or more upstream targets and a strategy. Strategies implement rolter_balancer::LoadBalancer:

#![allow(unused)]
fn main() {
pub trait LoadBalancer: Send + Sync {
    fn name(&self) -> &'static str;
    fn pick(&self, ctx: &RouteContext, loads: &[u64]) -> Option<usize>;
    fn pick_eligible(
        &self,
        ctx: &RouteContext,
        loads: &[u64],
        eligible: &dyn Fn(usize) -> bool,
    ) -> Option<usize> {
        self.pick(ctx, loads)
    }
    fn observe(&self, target: usize, ctx: &RouteContext) {}
}
}

pick returns an index into the route’s targets; observe lets learning strategies (cache-aware) record what a target served. The gateway calls pick_eligible, passing which targets the attempt can use (not tried, not parked, healthy, breaker not open, allowed to the key). The default ignores that and calls pick, since the gateway skips an ineligible pick itself; cache_aware overrides it so a target that takes no traffic cannot shape its load comparison; power_of_two and the pipeline stacks do not yet (#1942). RouteContext carries an optional session_key (from x-session-id) and the request prompt used for affinity scoring. The gateway bounds prompt to the conversation’s first 32 KiB and passes the whole prompt’s size and identity alongside as prompt_len and prompt_digest (caching.md).

Strategies (v1)

  • round_robin — sequential rotation; predictable, zero state.
  • random — uniform random; good for simple homogeneous pools.
  • power_of_two — pick the less loaded of two random targets; needs a load snapshot.
  • consistent_hash — hash-ring keyed by session_key (falls back to a digest of the whole conversation: its text plus the identity of its images, tool calls and tool results, or to the raw body when the gateway could not read one); pins a session/user to a target for KV reuse, survives target changes with minimal reshuffle (100 vnodes per target).
  • cache_aware — approximate prefix affinity on the conversation text, with a load guard: a warm replica keeps its affinity until it runs more than 2 requests and 1.5× past the least-loaded usable one, then the request spreads (#1851). Unhealthy, parked and breaker-open targets are left out of the comparison through LoadBalancer::pick_eligible; see caching.md.
  • weighted — smooth weighted round-robin honouring each target’s weight.
  • pipeline — composable filter → weighted-score → argmax selection: eligibility filtering drops ineligible targets, then a stack of Scorers (session affinity + static weight + in-flight load + prefix-cache affinity) is combined as a weighted sum and the argmax wins (ties broken randomly). Session affinity pins repeat requests from the same x-session-id to their last-served target (TTL-bounded) for warm-cache reuse. The extension point every future cost/latency/KV-cache scorer plugs into.
  • precise_cache_aware — consumes each target’s vLLM ZMQ KV-event stream and scores the exact leading fraction of caller-supplied token blocks resident on that target. Missing token ids and stale, malformed, disconnected, or sequence-gapped streams stay neutral; least-load routing remains the fallback.
  • lmcache_aware — polls each target’s configured LMCache controller signal and prefers available caches with free capacity (1 - occupancy). Empty, saturated, failed, and stale controllers stay neutral and fall back to least load.
  • adaptive — a weighted blend of observed latency, catalog cost and in-flight load, governed by the deployment-wide [adaptive_routing] policy. See below.
  • predicted_latency — rank targets by what this request is modelled to cost on each of them, from the queue it would join and its own prompt size, rather than by a per-target average. See below.
  • lora_aware — LoRA-adapter affinity for a fleet serving many adapters over shared base weights: prefer a target that already holds the requested adapter resident, with prefix affinity and in-flight load behind it. See below.

Which strategies read a target’s weight

build_with_stats hands the weight slice to three strategies: weighted, pipeline (its static-weight scorer) and adaptive (its fallback stack). Every other strategy is built from the target count alone, so a weight on one of their targets is stored and never consulted. The dashboard mirrors that list as usesWeights() in ui/src/lib/strategies.ts: the Model Catalog, the model sheet and the Routing Rules cards state a traffic share only for those three, and say the weights are ignored everywhere else (#1979, #2133). A new strategy that reads weights has to be added there too, or the dashboard will tell operators its weights do nothing.

Balancer lifetime, and provider groups

A balancer belongs to the pool, not to the request. Most strategies carry selection state — round_robin’s counter, weighted’s running weights, cache_aware’s trie, adaptive’s samples — and that state is exactly what makes them balance. A balancer built per request is not a balancer: a fresh RoundRobin always returns index 0, and a fresh WeightedRoundRobin always returns the heaviest target, so every request lands on the same place.

Configured routes get this for free: Snapshot::build constructs one balancer per route and hands out references to it for the snapshot’s whole life, and a config reload builds a new snapshot (and so a new balancer, deliberately — a new snapshot may have different targets, and a carried-over index would name a different provider).

A provider group address (group-slug/model, ADR-0017 addendum) is the case that is not free. A group has no configured route: Snapshot::resolve_pinned builds a synthetic one per request, because the model each member forwards depends on what the caller asked for. Only the target list is per request, though — the strategy and the member weights are fixed for the life of the snapshot, so the group’s balancer is built once in Snapshot::build, stored in Snapshot::group_balancers keyed by slug, and shared by every synthetic entry built for that group. Before #1655 it was built inside resolve_pinned, and a whole fleet’s traffic went to whichever member was listed first, whatever the group’s strategy and weights said.

Costs for a group’s balancer come from each member’s declared upstream_model, since the requested model is not known at build time and a group-slug/model address is never itself a priced model. A group whose members are all passthrough is therefore cost-neutral, which is the right answer for the homogeneous fleet a group exists to describe.

A provider slug address pins a single provider, so its pool has one target and no selection state to keep; that balancer is still built per request.

Adaptive routing

adaptive is the only strategy whose behavior is owned by a global policy rather than the route:

[adaptive_routing]
enabled = false           # kill switch; the whole feature is off by default
latency_weight = 1.0
cost_weight = 0.5
load_weight = 0.25
exploration_ratio = 0.05  # clamped to [0, 0.5]
min_samples = 50

Three conditions must all hold before the blend routes a single request: the kill switch is on, at least one weight is non-zero, and the route has both served min_samples requests and gathered latency samples for at least two targets. Until then — and immediately again if the policy is switched off — every pick goes to the same pipeline stack the route would have used otherwise, so moving a route to adaptive shifts no traffic on its own. The fallback stack keeps learning while the blend is engaged, so disengaging lands on a warm session/prefix cache.

Warm-up

The latency condition cannot be satisfied by waiting (#1645). The fallback stack is free to send every pick to one target — a configured weight, session affinity or prefix affinity all do this legitimately — and the peer it skips then never earns the latency sample the evidence check is counting. Before the fix a route on adaptive over such a pair sat at engaged: false with fallback: 50 of 50 picks and samples: 0 on the unsampled target indefinitely, which made adaptive permanently equal to the strategy it replaced.

So while the evidence is thin, a disengaged route steers picks to the targets that have no latency sample yet, at most eight per target. Those picks are counted as exploration in the decision split, since they buy evidence rather than the best target. The budget is what bounds the cost: a target that never answers absorbs at most eight picks in total and is then left to the fallback stack, rather than swallowing a permanent share of the route. The budget is eight rather than one because a latency sample lands only when a request finishes, so a smaller one would be spent on picks still in flight and the warm-up would give up on a healthy target under concurrency. The kill switch still outranks all of this: a policy with enabled = false probes nothing.

The policy is also owned by the control plane: GET/PUT /api/v1/adaptive-routing-policy (superadmin only) persists it, audits the change as adaptive_routing_policy.update, and returns the affected_routes the change reaches. It travels to the data plane in the normal snapshot, so a change applies without a restart. The API refuses an all-zero blend outright — stopping adaptive routing is what enabled = false is for.

Once engaged, an exploration_ratio share of picks is made uniformly at random so a target the blend has learned to avoid keeps producing fresh latency samples instead of going dark. Operator input is clamped on the way in: negative weights become zero and exploration never exceeds half the traffic.

Telemetry

The scores the blend ranks on live in the balancer, which lives in the gateway process, so the control plane cannot read them directly. Each gateway therefore pushes a sample every 15s to POST /internal/adaptive-telemetry on the control plane — the same internal token and the same x-rolter-node-id identity as the snapshot poll, so one node is one row here and in the cluster inventory (#543). Nothing is written on the request path: the sample is taken by a background task, and a pick costs two relaxed atomics for per-target attribution.

Per route and target the sample carries the blended score, its latency/cost/load components, the raw signals behind them (smoothed latency in ms, catalog price, in-flight count), how many picks that target has served and how long ago the last one was — plus the decision split, and the sanitized policy that node actually runs, which can lag the stored policy until the node converges.

The control plane keeps the newest sample per (node, model) in adaptive_routing_telemetry and serves GET /api/v1/adaptive-routing-telemetry (superadmin only), grouped by route with one entry per reporting node. Samples older than 60s are excluded as no longer current, and rows are pruned after an hour so a scaled-down node leaves the scoreboard.

This is deliberately current state, not history: one row per node and route, overwritten on every report, so the table is the size of the fleet rather than of the traffic. A time series belongs in the request log, and would be a separate endpoint rather than a change to this one. Prometheus deployments get the same per-target scores without the dashboard from rolter_adaptive_routing_target_score{model,target}.

LoRA-aware routing

For a fleet serving many LoRA adapters off shared base weights, routing a request to a node that already holds the adapter is the same class of win as prefix-cache affinity (#853, borrowed from llm-d). lora_aware composes with the existing scorers rather than replacing them:

ScorerWeight
adapter residency1.0
prefix affinity0.5
in-flight load0.25

Adapter residency outranks prefix affinity because the costs are not comparable: missing a warm prefix recomputes some tokens, while missing a resident adapter can force the engine to load adapter weights before it decodes anything at all. Load stays in the stack so a fleet where every target holds the adapter still balances instead of pinning.

Residency is learned from traffic, not declared. rolter cannot see which adapters an engine currently holds, and a static declaration would go stale the moment the engine evicted one. Each target keeps a bounded LRU set of the adapters it has recently served, sized to mirror vLLM’s --max-loras, so it tracks what the engine plausibly still holds rather than everything it has ever served. An unbounded “has ever served” set would be worse than no scoring at all, because it steers confidently to a target that went cold long ago.

Adapter identity is the requested model, which is how vLLM addresses adapters over shared base weights. The gateway only sets it when the request addresses something other than the route’s own model — that is, a passthrough provider-group route (ADR-0017). On a single-model route the two are equal, no adapter is set, and the scorer is inert. This matters: adapter affinity deliberately pins rather than spreads, so treating a route’s one model as an adapter would pin the entire route to whichever target happened to serve first.

When no candidate holds the adapter — a cold adapter, or a request with none — every candidate scores 0.0. That is neutral rather than a penalty: an equal contribution cannot move the argmax, so the rest of the pipeline decides and this scorer stays silent instead of guessing.

Predicted-latency scheduling

fastest ranks on a per-target latency EWMA. That is the right signal when every request is the same size, and the wrong one otherwise: a target whose average is high because it happened to serve the long prompts looks slow even when it is the emptiest box in the fleet, and a target with a deep queue looks fast right up until the queue is what you join.

predicted_latency models the cost instead of averaging it (#853, borrowed from llm-d). Per target:

latency_ms ≈ w0 + w1 · queue_depth + w2 · prompt_ktokens

w0 is fixed overhead, w1 is what one queued request ahead of you costs, and w2 is prefill cost per thousand prompt tokens. The three coefficients are learned online from completed requests with normalized least mean squares — one multiply-add per feature per sample, no matrix, no allocation, no periodic refit.

ScorerWeight
predicted latency1.0
in-flight load0.25

Load stays in the stack for two reasons: it carries the route while the models are cold, and once they are warm it is the tiebreaker between targets the model rates equally — the common case on a homogeneous fleet, where the honest answer is “either, take the emptier”.

Three features, not more. A gateway sees queue depth and prompt size. It does not see batch composition, KV-cache pressure, or where the engine is in its scheduling loop, and a model with parameters it cannot observe fits noise. The interesting error is between “the queue is deep” and “the prompt is long”, and a linear model separates those. That is the honest ceiling for this vantage point.

A cold target predicts nothing. Below 8 completed requests a target returns no prediction, and the scorer reads that as unknown, not as slow. A route that switches to predicted_latency therefore behaves exactly like the least-load pipeline until the models have evidence, so the switch itself moves no traffic.

Only successful requests teach. The same gate the latency EWMA uses: a fast failure cannot train a target into looking cheap. Coefficients are clamped, so a client that held a stream open for a week produces one bad sample rather than a permanently poisoned model.

Queue depth is read before the increment, so the model learns the queue a request joined, not the one it created.

Models live in the load tracker, not the routing snapshot, for the same reason the latency EWMA does: a config reload must not throw away what they learned, or every reload would send the route back to cold behaviour. A route that gains a target keeps its existing models and the new target reads as unpredicted until the next process start — the conservative direction, since a target with no evidence should not be ranked.

Choosing a strategy

Use caseStrategy
Homogeneous pool, statelessround_robin / random
Variable request durationspower_of_two
Multi-turn chat, sticky sessionconsistent_hash
Shared system prompts / few-shot / RAGcache_aware
Blend cache + load + weight signalspipeline
vLLM fleet with KV event publishingprecise_cache_aware
LMCache fleet with occupancy controllerlmcache_aware
Mixed-price providers, minimize spendcheapest
Heterogeneous pool, minimize latencyfastest
Mixed price and latency, let the gateway tuneadaptive
Many LoRA adapters over shared base weightslora_aware
Heterogeneous pool, variable prompt sizes, deep queuespredicted_latency

Both external strategies perform network I/O only in background tasks. The request hot path reads bounded in-process state and atomics.

Selecting a strategy from the dashboard

The route and provider-group editors offer every strategy in this table except adaptive, which is governed by the deployment-wide [adaptive_routing] policy and has its own screen — a per-route dropdown would misrepresent how it is controlled. precise_cache_aware and lmcache_aware are offered with a hint that they need a telemetry source on the target providers, since without one they fall back to least-load silently rather than failing.

The hint also says where that source is set. [providers.kv_events] and [providers.lmcache] exist only on a provider defined in rolter.toml: the postgres store loads every database provider with both set to None, and neither the CRUD API nor the provider sheet has a field for them. A route or group whose targets are all dashboard providers therefore always runs on the least-load fallback under these two strategies, and the hint says so instead of sending the operator to a provider setting that does not exist (#2137). Storing both on database providers is tracked in #2236; when it lands, that part of the hint goes. When the deployment has a documentation host, the hint links the user-facing cache-aware routing page through DocsLink; an air-gapped deployment with none gets the hint without a link.

The two strategies stay selectable: a route or group can target providers from rolter.toml that do carry a source, and an operator who wired one up must be able to choose them.

A picker always renders the value the route or group already holds, even one it would not otherwise offer — including a strategy set from rolter.toml or the API, or one added to the backend allowlist ahead of the dashboard. A native <select> whose value matches no option displays the first option instead, so before #897 a group balanced by adaptive read as round_robin. Whatever the menu chooses to offer, editing must never rewrite a strategy the operator did not touch.

Caching

rolter deals with two distinct kinds of caching.

1. KV-cache affinity (load balancing)

The big win for self-hosted fleets: vLLM/SGLang reuse the attention KV cache for shared prompt prefixes (system prompts, few-shot examples, conversation history). But that only helps if the next matching request lands on the same replica. Naive round-robin scatters related requests and destroys cache locality.

The cache_aware strategy keeps, per target, a byte trie of prompts it has served. For an incoming prompt it computes the fraction of leading bytes already present on each target and:

  • if the best match ≥ threshold (default 0.5), pins the request to that target (cache hit) — unless that target is overloaded (below)
  • otherwise spreads to the least-loaded target, or the least-warmed one when no load is known

The “prompt” is the conversation, not the request body (#1851). prompt_affinity::affinity reads each turn’s role and text in the order the model sees them — messages for chat, system plus messages for Anthropic, instructions plus input for Responses, prompt for completions — and the raw body is only the fallback for a shape it cannot read. Before #1851 the trie was fed the raw JSON, and the envelope every request to a model opens with ({"model":"…","messages":[{"role":…) cleared the threshold by itself, so unrelated requests all pinned to whichever replica served first.

Only the first 32 KiB of that text is copied into RouteContext::prompt, cut on a character boundary, since prefix affinity is decided by the leading bytes: the trie walk stays bounded and a long prompt never costs a second full copy of itself. The whole prompt is still measured and hashed as it is read, and travels as RouteContext::prompt_len and RouteContext::prompt_digest (xxh3). Both cover what the text leaves out. Tool calls and results (Anthropic tool_use / tool_result, OpenAI tool_calls, Responses function_call / function_call_output) are prefilled like any other token and are often most of an agent’s prompt, so they count towards the length and the digest. Images, audio and files count towards the digest only, since their encoded size says little about their token cost. Neither is copied: a non-string value is serialized straight into the hasher. prompt_affinity::route_context builds the context for every JSON request, so the proxy and the tests hand the balancer the same thing. Each prompt reader takes the part it needs:

  • cache_aware and the pipeline’s prefix scorer read the bounded text.
  • consistent_hash without a session keys on the digest, so requests that share a long system prompt and differ after it, or differ only in an attached image or tool result, still spread over the ring. Hashing the bounded text would send all of them to one target.
  • The latency predictor’s token estimate is prompt_len / 4, so a long prompt keeps growing in size past the cut instead of stopping at 8192 tokens, and a tool-heavy agent turn is sized by its tool results too.

The load guard. Affinity alone is winner-take-all: the replica that served a shared system prompt first holds the longest match for every request that shares it. After SGLang’s cache-aware router, CacheAware::pick keeps the affinity only while the warm target’s in-flight count stays within both balance margins of the least-loaded target — more than BALANCE_ABS_THRESHOLD (2) requests ahead and more than BALANCE_REL_THRESHOLD (1.5×) its load is an imbalance, and the request goes to the least-loaded target instead. The absolute margin lets a warm replica take a short queue for its cache; the relative one keeps a busy pool from treating a gap of a request or two as an imbalance. With no load known there is nothing to balance against, and affinity stands.

Every comparison runs over the targets the gateway can use for the attempt. pick_untried passes its eligibility (not tried yet, not parked on a cooldown, healthy, breaker not open, allowed to the caller’s key) through LoadBalancer::pick_eligible, and CacheAware restricts the affinity match, the least-loaded minimum and the fallbacks to those targets. A replica that takes no traffic drains to an empty queue, and counting its zero made every warm replica with three requests in flight look overloaded, which sent the spill to the dead replica and from there to target 0 by index. When nothing is eligible the pick weighs every target, as a plain pick does, and the gateway fails open as before. The variant path passes the same rules to each variant’s balancer (variant_target_eligible). The eligibility is asked once per target and kept in a word for routes of up to 64 targets, so a pick allocates nothing. Each answer still takes the cooldown and breaker locks, and power_of_two and the pipeline stacks still pick blind to it; both are #1942.

This is approximate (no coupling to the engine). The per-target trie is capped at a node ceiling (default 1M nodes) with LRU eviction: inserting past the cap drops the least-recently-inserted prompt, pruning only the nodes that become unreferenced (shared prefixes survive). Each trie exposes an eviction counter for observability.

flowchart TD
  R[incoming prompt] --> S{best prefix match >= threshold?}
  S -- yes --> B{best target past both balance margins?}
  B -- no --> P[pin to best target<br/>cache hit]
  B -- yes --> L
  S -- no --> L[least-loaded / least-warmed target]
  P --> O[observe: insert prompt into target trie]
  L --> O

Precise vLLM mode

Set a provider’s [providers.kv_events] block and choose strategy = "precise_cache_aware". rolter supports the vLLM V1 msgpack KVEventBatch protocol documented for vLLM 0.22: a three-frame ZMQ publication (topic, big-endian sequence, payload) containing tagged BlockStored, BlockRemoved, and AllBlocksCleared events. BlockStored.token_ids and block_size derive stable local prefix identities; external block hashes allow exact removals. State is capped by max_blocks per provider.

Exact scoring needs the token ids produced by the same tokenizer as vLLM. Send them in x-rolter-vllm-token-ids as comma-separated unsigned integers. Without the header, with stale/malformed events, or after a sequence gap, the scorer is neutral and least-load routing takes over. A sequence gap clears the local index and precise scoring remains disabled until an AllBlocksCleared event establishes a clean boundary.

[[providers]]
name = "vllm-a"
kind = "openai_compatible"
api_base = "http://vllm-a:8000"

[providers.kv_events]
endpoint = "tcp://vllm-a:5557"
topic = "kv-events"
max_blocks = 1000000
stale_secs = 30

vLLM must enable KV events with the ZMQ publisher and matching topic. Metrics expose consumed/malformed events, stream failures, decision count, and per-provider freshness.

LMCache-aware mode

Set [providers.lmcache] and use strategy = "lmcache_aware". The supported controller signal is an HTTP 200 JSON object:

{"occupancy": 0.42, "cache_available": true}

occupancy is clamped to [0,1]; available targets score 1 - occupancy, unavailable targets score zero. Polling happens in the background. Failed, malformed, or stale signals are neutral, so existing least-load routing continues.

[providers.lmcache]
endpoint = "http://lmcache-a:9000/v1/occupancy"
refresh_secs = 2
stale_secs = 10

2. Response cache

Optional caching of full responses to cut cost/latency for repeated requests:

  • exact: hash of the normalized request → cached response (Redis), short TTL, opt-in per route/key.
  • semantic: after an exact miss, embed the normalized conversation text through a configured provider and compare cosine similarity against a bounded recent-entry window in Redis. The route controls the threshold and candidate cap. Embedding, Redis, and decode failures fail open to normal routing. Only compatible requests are ever compared — see Semantic compatibility.

Streaming responses are cached on completion and replayed as a synthetic stream. Cache status is surfaced via response headers (e.g. x-rolter-cache: hit|miss).

[cache]
enabled = true

[routes.cache]
enabled = true

[routes.cache.semantic]
provider = "openai"
model = "text-embedding-3-small"
threshold = 0.92
max_candidates = 256

Post-response policy on cached responses

Enabling the cache must never change which policy a completion passes through (#1477). A buffered, successful body is finalised by exactly one function, DeliveryPolicy::apply in crates/rolter-gateway/src/handlers.rs. It runs the built-in output guardrails, then the post_response plugin chain, then the PII sanitizer’s response leg, and returns either the body or the refusal to send instead. Every delivery path calls it:

PathWhere
live non-streaming response, not cachedstream_response’s buffered branch
cacheable miss (the response being stored)the cache-store branch of proxy, after put
exact hit and semantic hitcached_response

The entry is written before the policy runs, so it is always the upstream’s own bytes. A transform is re-applied per delivery. A rule or plugin installed by a hot reload reaches entries cached before it. A PII restore uses the ticket of the request being answered and is never persisted. A refusal is returned as Delivery::Refuse with the reason for the log row. On the live and cacheable-miss paths it goes through withheld_response, so the upstream call is still billed. A refused hit writes a zero-cost withheld row (see Billed but withheld). Streamed responses skip all three stages exactly as they do uncached (the request path has already refused the stream or had it opted out), so a cached SSE replay gets the same treatment as a live stream.

crates/rolter-gateway/src/cache_policy_tests.rs holds the HTTP regression suite. It covers cache off, the first miss, and exact and semantic hits, with blocking, transforming and unreachable (fail-closed and fail-open) plugins, a hot reload that installs a plugin over existing entries, and per-request PII restoration with a check that nothing restored reaches the store.

Semantic compatibility

A semantic hit replays a reply produced for a different request, so similar wording is not enough on its own: two requests can read alike and still expect incompatible replies (#1476). crates/rolter-gateway/src/semantic.rs splits every request into two parts before any similarity is computed:

  • the text that is embedded: the user and assistant turns (with an OpenAI name), or the prompt of a legacy completion;
  • a partition: a sha-256 over the canonical, key-sorted form of every other field of the post-injection body, including stream, stream_options, tools, tool_choice, response_format, sampling and length parameters, the model, and the dialect. OpenAI system/developer messages (with their position) and the Anthropic top-level system are partition fields too, so instructions are matched exactly rather than by similarity. "Answer in German" and "Answer in Japanese" embed almost identically, so this is the only safe way to compare them.

The partition is part of the Redis index key, so the candidate scan never sees an entry from another partition. It is an allowlist of what may vary, not a list of what must match. Only the conversation text, user and metadata are left out (attribution that does not change the reply), and stream: false is folded into an absent stream. A field the gateway does not know splits the cache, which costs hit rate but never correctness. As a last check before replay, a hit whose stored content type disagrees with the caller’s stream flag is treated as a miss.

Requests whose meaning the text would not capture bypass semantic lookup before the embedding call, and so cost no embedding spend. They still use the exact cache.

ShapeSemantic lookup
/v1/chat/completions with text-only system/developer/user/assistant turnsyes
/v1/messages with text-only user/assistant turns and a text systemyes
/v1/completions with a single string promptyes
any non-text content part (images, audio, files, documents)bypassed
tool traffic inside the conversation (tool_calls, tool turns, tool_use/tool_result blocks)bypassed
a batch or token-id prompt, or a turn carrying any key other than role, content and namebypassed
/v1/embeddings, /v1/audio/*, /v1/images/*, /v1/rerank (the output must match the input exactly)bypassed
/v1/responses (not response-cached at all)bypassed

Offering tools in the request (tools, tool_choice) is supported. The schema is a partition field, so a reply is only replayed to a request that offered exactly the same tools.

The index key carries a layout version (<namespace>:semantic:v2). Entries written before #1476 had no partition and live under the old <namespace>:semantic key, so after an upgrade they are never read again and expire on their TTL. Bump SEMANTIC_LAYOUT_VERSION whenever the partition rules change.

The HTTP regression suite is crates/rolter-gateway/src/semantic/http_tests.rs. It serves the gateway in-process against a stub upstream whose embeddings are identical for every input, with the in-memory cache backend (ResponseCache::in_memory, test builds only), so it needs neither Redis nor a paid model. Because similarity alone would match any two requests there, each miss it asserts is the partition or the bypass at work, and each hit shows the partition does not over-split.

Configuration & hot reload

A core requirement: operators change routes, providers, keys, limits and pricing from the UI and have them take effect without restarting the gateway.

Sources of config

  1. Bootstrap file (rolter.toml) — used for first run, local dev, and IaC. Maps to rolter_core::GatewayConfig.
  2. Database (Postgres) — the runtime source of truth once the control plane is running. The control plane composes a GatewayConfig-equivalent snapshot from normalized tables.

Propagation

sequenceDiagram
  participant UI
  participant Control as rolter-control
  participant PG as PostgreSQL
  participant Redis
  participant GW as rolter-gateway
  UI->>Control: PUT /api/v1/routes/... (RBAC checked)
  Control->>PG: write change in a transaction
  Control->>PG: bump config_version.version
  Control->>Redis: PUBLISH rolter.config {version}
  Redis-->>GW: message {version}
  GW->>Control: GET /internal/snapshot?version=N
  Control-->>GW: full snapshot (JSON)
  GW->>GW: build Snapshot, ArcSwap::store (atomic)
  • The gateway keeps the routing table in an ArcSwap<Snapshot>. Swapping is atomic and wait-free for readers — in-flight requests keep using the old snapshot; new requests see the new one.
  • Versioning: config_version in Postgres is the monotonic source of truth. The gateway also reconciles on an interval (and at startup) so a missed pub/sub message self-heals.
  • Validation: the control plane validates a snapshot (every route target references a known provider, etc.) before bumping the version, so gateways never load a broken config.
  • Per-row resilience: validation used to be all-or-nothing, so a single half-built row 500’d /internal/snapshot and froze config propagation for every tenant. Rows that are unservable only by themselves are now omitted from the snapshot instead. That covers both a route with no usable target (created before its targets are added, or pointing at a deleted provider) and a provider whose own definition is invalid — a malformed api_base, a hosted adapter missing its api_key_env. Dropping a provider drops the routes that depended on it, since they are then left with no resolvable target. Structural problems that span rows (duplicate names, a bad metrics_path, unreadable CA bundles) still fail the whole snapshot, since silently dropping one of two colliding rows would be worse than refusing.
  • Saying what was dropped (#926): omitting an entry quietly is its own failure mode — every gateway keeps serving its last good config, so nothing looks broken until a gateway restarts cold or someone wonders why a change never took effect. So the reasons travel with the config:
    • /internal/snapshot carries a problems: [...] array alongside version and config, present only when something was dropped. It is absent from a healthy snapshot, which is what every gateway in the fleet transfers on every poll.
    • the gateway logs them once per change, at the point it applies a new version, rather than once per poll — a fleet polling every 5s would otherwise repeat the same complaint thousands of times a day. An older control plane sends no such field, so it defaults rather than failing the decode during a rollout.
    • the dashboard reads GET /api/v1/config/problems and shows them on the Providers screen. That endpoint runs the same computation the snapshot does, so the two cannot disagree; it also reports structural problems, which never reach a gateway at all.
    • that endpoint also appends ConfigStore::load_problems: rows the store’s loader could only map onto the config by guessing, such as a budget whose stored period it does not recognise and enforces as monthly (#1902). The snapshot carries the guess rather than the row, so only the store can say where one was made. Those lines are not in the snapshot’s problems, because nothing was dropped: the gateway serves the row, just not as the row says.
  • GET /api/v1/config is the dashboard’s read-only view of the same document and sits on the open router, so it answers without a session. It is therefore redacted before it leaves the control plane (redact_config_for_dashboard): provider credentials, the plaintext of [[virtual_keys]], the digests of database keys and every MCP OAuth session are removed, and userinfo is stripped from the ClickHouse and egress-proxy URLs. Anything a gateway needs to authenticate with comes only through the token-guarded /internal/snapshot (#1212).

Why this design

  • Redis pub/sub gives near-instant fan-out to many gateway replicas.
  • Postgres versioning makes the system correct even if Redis drops a message.
  • ArcSwap keeps the hot path lock-free; no read ever blocks on a config write.

Alternatives considered: Postgres LISTEN/NOTIFY (avoids a Redis dependency but Redis is already needed for cache/rate limits), and pure polling (simplest, higher latency). See ADR.

Config schema migrations

How a rolter.toml written for one release is carried across a version boundary, and why the mechanism is shaped the way it is (#326).

The operator-facing side of this is Upgrading Rolter. This page is the design.

The shape

crates/rolter-core/src/config_migrate.rs holds a chain of steps keyed by schema version:

#![allow(unused)]
fn main() {
pub static MIGRATIONS: &[Migration] = &[Migration {
    from: 1,
    to: 2,
    summary: "...",
    apply: tier_provider_sections,
}];
}

migrate() reads the file’s schema_version, runs every step whose from is at or above it, and stamps the result. N -> N+k is therefore the same code path as N -> N+1 — a fold over the chain — which is what makes skipping releases free rather than a separate feature.

GatewayConfig::from_toml_str calls it on every load, ahead of the tiered-section extraction, so a migration can still see providers and provider_groups as part of the document.

Three rules, each of which cost something to learn

A migration is behaviour-preserving or it is not a migration

The GatewayConfig parsed from the migrated document must equal the one parsed from the original. migration_is_behaviour_preserving pins this.

This is what makes it safe to migrate on every load rather than as a separate operator-invoked step. The moment a migration can change behaviour, running it implicitly at boot becomes the wrong design and the whole thing needs an apply/confirm cycle.

A file from the future loads, it does not fail

config_lint’s module docs already state the promise the config types make: no deny_unknown_fields, because a file written for X.y.z has to stay loadable by every other X.* build, including an older one it gets rolled back onto.

A version stamp is the obvious place to break that promise, and breaking it would be worst exactly when it hurts most — a rollback is what an operator reaches for when something has already gone wrong. So a schema_version newer than the build understands sets Report::ahead, logs a warning, and loads the document unchanged.

Migrate in memory, never in place

migrate() rewrites the parsed document. Nothing writes the file back.

A loader that edits its own input is a surprise in a crash-looping container, where the operator is least able to absorb one, and the config mount may be read-only anyway. Emitting a canonical rolter.toml is rolter config export’s job (and #1513 is making its output current).

What is deliberately not a migration

api_key / api_key_env / egress_proxy have plural successors and look like the obvious first migration. They are not one: rolter-control’s seed, snapshot and export paths read the singular fields directly rather than through resolve_api_key() and egress_proxy_pool(), so normalising the document would silently change what rolter-seed --import stores.

That is #1514. Until every reader goes through the accessor, these stay deserialize-time shims — which is how this repository has handled config evolution so far, and the reason the migration chain starts as short as it does.

Adding a step

  1. Append a Migration to MIGRATIONS with from equal to the current CURRENT_SCHEMA_VERSION and to one higher.
  2. Bump CURRENT_SCHEMA_VERSION. the_chain_is_contiguous_and_reaches_the_current_version fails if you forget, rather than the step silently never running.
  3. Add a fixture in the old spelling and assert it parses identically to the new one.
  4. Keep the step idempotent — migrating_twice_changes_nothing_the_second_time runs it against an already-migrated document.

If your step renames a key, config_lint needs to change with it

No current step consumes a key, so the lint and the chain do not interact. The first rename breaks that: the lint parses the file as written and would report the old spelling as an unrecognised key, telling an operator to fix something rolter already handled.

Do not fix this by linting the migrated document. The paths the lint prints are how an operator finds the line in their own file, and migrating first rewrites them into paths the file does not contain — providers[0].x becomes providers.readonly[0].x, which greps to nothing. Have the migration declare the key paths it consumes, and have the lint skip exactly those.

Previewing

rolter check --migrations renders the plan without writing anything, and adds a non-fatal finding so a CI job running --strict can notice a config drifting behind. It is opt-in because an operator who has never stamped their file would otherwise meet the warning on every run of a perfectly working deployment, and a warning everyone learns to skip past is worse than no warning.

Not covered here

The control-plane-to-gateway snapshot carries config_version, a change counter, not a format version — so a mixed-version fleet during a rolling upgrade has no negotiation. That is #1512, with its own ADR.

Data model

PostgreSQL is the source of truth. The initial schema lives in migrations/0001_init.sql; ClickHouse log schema in clickhouse/001_logs.sql.

Tenancy

erDiagram
  orgs ||--o{ teams : has
  teams ||--o{ projects : has
  projects ||--o{ routes : has
  routes ||--o{ route_targets : has
  projects ||--o{ virtual_keys : has
  orgs ||--o{ providers : has
  providers ||--o{ provider_keys : has
  users ||--o{ memberships : has
  orgs ||--o{ mcp_servers : registers
  users ||--o{ mcp_oauth_grants : consents
  mcp_servers ||--o{ mcp_oauth_grants : authorizes
  orgs ||--o{ mcp_tool_groups : governs
  orgs ||--o| mcp_gateway_settings : configures
  mcp_oauth_grants ||--o{ mcp_oauth_sessions : issues
  • Org → Team → Project → Virtual Key is the hierarchy. Budgets and rate limits attach at any scope and combine most-restrictive-wins.
  • Providers are owned at the org level and referenced by route targets. Upstream credentials live in provider_keys, envelope-encrypted (see security.md).
  • Routes belong to a project and map a public model to route_targets with a strategy.
  • Virtual keys belong to a project, store only a hash of the key plus a display prefix, and carry an optional model allow-list.
  • MCP servers belong to an org and declare required OAuth scopes and exposed tool names. Only enabled servers reach gateway snapshots. Grants bind a user to a server; sealed token sessions belong to a grant. MCP tool groups persist named server/tool manifests, while MCP gateway settings hold organization defaults for registration and MCP-aware clients; neither is currently a request-path authorization boundary.
  • Provider groups (provider_groups, provider_group_members) are org-scoped and address a fleet of same-kind providers as one group-slug/model name. The slug shares the provider slug namespace; a member’s weight and position feed the group’s balancing strategy, and a null upstream_model forwards the requested model unchanged.
  • Labels (labels) attach a (key, value?) fact to a provider, provider group, route or model. source separates an operator’s custom label from an auto one Rolter derived, and is part of the uniqueness constraint so the two never overwrite each other; auto rows additionally carry observed_at/observation. subject_id is text because a model is addressed by name, which rules out a foreign key — three after delete triggers sweep a subject’s labels instead. See labels.md.

Cost & limits

  • model_prices — USD per million tokens for input/output (+ cached input). Used to compute cost_usd per request, written to ClickHouse.
  • budgets — spend caps per scope and period; enforced before forwarding and refreshed from spend aggregates. unpriced_policy is an optional per-budget override of the deployment-wide unpriced-traffic policy (ignore | warn | block, null inherits); the gateway resolves most-restrictive-wins across the scope chain, so a budget can tighten it but never loosen it (#996). period is free text with no check constraint. The snapshot loader reads it through BudgetPeriod::parse, which knows the spellings in BudgetPeriod::SPELLINGS (daily/1d/24h, monthly/30d, total/lifetime/all), and the control plane refuses anything else on create and edit (#1902). A row written before that check can still hold another value, 7d being the one the dashboard used to suggest. The loader keeps enforcing it as monthly, since failing the snapshot would withhold every tenant’s config over one row and moving a budget onto another window is not a guess to make for the operator, and ConfigStore::load_problems reports it so GET /api/v1/config/problems can say so. No migration rewrites those rows for the same reason. The dashboard keeps its own copy of the spelling table in ui/src/lib/budget-period.ts, which names the window on a card and offers the picker’s options; budget-period.test.ts reads BudgetPeriod::SPELLINGS out of config.rs and fails when the two differ.
  • rate_limits — RPM/TPM per scope; counters live in Redis for multi-instance correctness.

Both cap tables are edited in place (#1285). PATCH /api/v1/budgets/{id} and PATCH /api/v1/rate-limits/{id} go through BudgetRepo::update and RateLimitRepo::update. Each locks the row with select … for update, works out whether the edit moves anything, and only then issues a single update statement in the same transaction, so the statement-level trigger from 0004_config_version_pricing_limits.sql bumps config_version once per edit. An edit that moves nothing issues no statement at all: a statement-level trigger fires on an update that matches no row too, so a no-op filtered out in the where clause would still bump and wake every gateway. limit_usd is compared as numeric(12,4), so 500.0 against a stored 500.0000 is no change. The rate-limit rule that a row keeps at least one positive cap is checked against the locked row as well, because two concurrent edits each lifting a different cap would both pass a check made on an earlier read. Both methods hand back the row before and after (Edited<T>), and the audit changes diff is built from that pair. Deleting and recreating a row instead would bump twice with no cap in between, and a gateway that polled in that gap would load a scope with nothing on it. The row keeps its id and created_at, and the scope is not editable. Redis spend counters are keyed by scope, scope id and period window rather than by budget row (rolter:budget:<scope>:<id>:<window>), so raising or lowering a limit keeps the spend already counted this period, while a changed period reads the new window’s counter. The handlers also publish the new version on CONFIG_CHANNEL, the same as every other snapshot input, so a Redis-subscribed gateway refetches at once instead of at its next poll.

Config versioning

config_version holds a single monotonic counter the gateways watch for reload-free updates (config-and-hot-reload.md). audit_log records who changed what.

The RBAC tables are split on exactly this question. access_profile_policies, access_profile_assignments, access_profiles and memberships all carry a bump_config_version() trigger, because the gateway resolves the model/route policy of each virtual key’s owner from them and enforces it on the request path (#791, ADR-0023). custom_roles and custom_role_grants do not and must not: they decide control-plane authorization, which is evaluated live per request, so a bump would wake the fleet for a change it cannot observe.

Since #1841 the snapshot also decides from users and access_profile_roles whether a personal virtual key (one whose created_by names the person who minted it for themselves) is served: its creator must be active and still hold a role that reaches the key’s project. Migration 0075 gives access_profile_roles a bump_config_version() trigger, and users one that fires only on deactivated_at and is_superadmin. It also adds a before delete trigger on users that disables the account’s personal keys: virtual_keys.created_by is on delete set null (0016), so without it deleting a leaver would turn their keys into ownerless shared keys. See RBAC & authentication.

Routes, providers and provider groups reach the snapshot with the org that owns them (and a route with its project) as tenancy, which the gateway uses to keep each org’s keys on its own rows (#1844). The columns already existed — providers.org_id, provider_groups.org_id, routes.project_id — so this took no migration; see RBAC & authentication.

provider_groups and provider_group_members carry one too. The data plane indexes config.provider_groups to resolve group-slug/model addressing, so a group is data-plane-visible the moment it exists — and until #1643 neither table bumped, which meant a group created in the dashboard was present in /internal/snapshot and still 404’d at the gateway until it was restarted or an unrelated config write happened to bump the counter for it.

plugin_instances is the control-plane registry for desired request/response middleware configuration. A row belongs to an org and may narrow to a project; slugs are unique within that scope. Endpoint credentials are environment-variable references and config is always a JSON object. The table deliberately has no bump_config_version() trigger until the allocation-light gateway dispatcher in #509 consumes it—an enabled registry row is configuration, not a false claim that middleware is running.

The guardrail registry is deployment-wide:

  • guardrail_rules stores the ordered built-in and bounded-regex policy managed by the dashboard. Exactly one of builtin or pattern is present on every row.
  • guardrail_providers stores external webhook endpoints and environment-variable credential references. A partial unique index permits at most one enabled provider because the gateway exposes one vendor-neutral webhook contract.

Both tables bump config_version in the write transaction. File-owned rules remain immutable and win name collisions; database rules extend that policy. An enabled file-owned webhook remains authoritative, otherwise the active registry provider supplies the snapshot webhook. The Guardrail Providers screen reports that merged webhook from GET /api/v1/config rather than the registry’s enabled flag (#2162). It tells the two sources apart by comparing the webhook field by field with the enabled row (ui/src/lib/guardrail-enforcement.ts), so a file webhook identical to that row is credited to the row until the API reports the source (#2249). A post_call webhook is reported as not enforcing, since the gateway runs only the pre-call stage. The Guardrail Rules screen reads the merged guardrails section of the same document (#2157): a rule no row accounts for is listed as the config file’s, a row whose name a differing effective rule takes is reported as overridden, and feature_flags.guardrails = false turns every card to not enforced (ui/src/lib/guardrail-policy.ts). It only trusts a document whose guardrails.enabled equals feature_flags.guardrails, which the store guarantees on every load; the default document get_config falls back to when the store fails breaks that, so it is reported as unreadable rather than as an empty policy. A file rule identical to an enabled row is credited to the row, as with the webhook, until #2249 reports the source.

Several deployment-wide settings are stored as singleton tables: one row keyed by id boolean primary key default true check (id), seeded by their own migration, so a read never has to handle “not configured yet”. runtime_policy, compatibility_policy, security_settings, logging_settings, client_settings and model_defaults all follow this shape and all bump config_version in the write transaction. logging_settings also holds ui_events (#1748), the deployment-level opt-out for the dashboard UX stream; the control plane reads it rather than the gateway, and the table’s existing per-statement trigger covers the column.

Two of them landed with the Settings screens (#564):

  • client_settings — the base URL the dashboard advertises (advisory; the gateway never reads it), the allowlist of inbound client headers forwarded to the upstream provider, the static headers the gateway injects on every upstream request, and the request-id header. Trace-context headers propagate independently of the allowlist, and injected header values are treated as credential material: they reach the gateway through the snapshot but never the audit log, which records only the names.
  • model_defaults — an enabled kill switch plus optional default_model, default_temperature, default_top_p and default_max_tokens. Defaults only ever fill a key the request omitted, so the table can be populated without changing the meaning of any request that was already explicit. default_temperature and default_top_p are double precision, not real: an f32 default serializes into JSON as 0.800000011920929 and that is what would reach the provider.

Data written by the data plane

Most tables flow control plane → gateway. Two flow the other way, written from the channel the gateway already holds and never read back by it:

  • cluster_nodes — one row per node, upserted from the snapshot poll (#543).
  • adaptive_routing_telemetry — one row per (node, model) holding the newest adaptive-routing sample that node pushed (#751): the decision split, the sanitized policy it runs, and a JSON document of per-target scores and signals. A scoreboard, not history — every report overwrites the row, so the table is the size of the fleet rather than of the traffic.

Neither carries a bump_config_version() trigger, and neither may grow one: the data plane does not consume them, and a version bump on every heartbeat or sample would make routine bookkeeping look like a config change and wake the whole fleet.

ClickHouse tables

PostgreSQL holds configuration; ClickHouse holds the high-volume append-only streams, all partitioned by day with a 90-day TTL and written in batches off the hot path. Schema lives in clickhouse/, applied by the container’s init directory:

  • request_logs — one row per proxied request, with cost and token counts.
  • provider_health_events — per-target health observations from every signal.
  • mcp_tool_call_logs — MCP tool invocations.

Every one of these tables declares ts DateTime64(3) default now64(3), but that default is a fallback for old writers only. Because the writers batch, a row that relies on it is stamped with the flush time rather than its own, so each writer sends ts explicitly: request_logs and its request_payloads row use the instant the request began, provider_health_events the instant the observation was made, and mcp_tool_call_logs the ts the submitter reports for the tool call (falling back to ingest time). See Observability for what each instant means; rows written before #1210 carry flush time.

  • ui_events — dashboard UX events (#805): screen views and time-to-interactive, navigation and back-outs, form submit/abandon and which validation rules fire, empty- and error-state impressions, save-to-confirmation latency. Carries trace_id, so a UX event and the gateway request it caused are one join apart. ts is the instant the interaction happened, stamped by the browser as the event is queued rather than when the batch reaches the control plane (#1224): the dashboard flushes on a timer and at unload, so ingest time collapsed a whole burst onto one instant and pushed an event minutes away from the request it joins on trace_id. Because that instant comes from a clock the server does not own, it is believed only within a window around ingest time — more than five minutes ahead or more than a day behind and the ingest instant is recorded instead. The window is deliberately not a clamp: pinning a skewed clock to the boundary would manufacture a cluster of rows exactly five minutes out, which reads as real traffic. Rows written before #1224, and any client that sends no ts, carry ingest time.

ui_events is structurally incapable of holding content: every column is a key, an enum, a duration or an id, so “no form values, no prompt text” is a property of the schema rather than a policy applied on write. None of these tables is read by the data plane, so none carries a bump_config_version() trigger.

Migrations are append-only

Migrations are embedded with sqlx::migrate! and sqlx stores a checksum of every migration it applies. If a shipped file’s bytes change — even a stripped trailing newline, as a formatting hook once did in #710 — every database that already ran it refuses to start on its next upgrade:

Error: store error: migration 18 was previously applied but has been modified

CI databases always start empty, so nothing else catches this: the change merges green and breaks deployments later. Never edit, delete or rename a file under crates/rolter-store/migrations/; add a new NNNN_*.sql instead. Numbers are append-only and never reused, even where the sequence has a gap.

scripts/check-migrations-immutable.sh enforces this. It runs as the migrations append-only step of the static checks job in quality.yml (inside the ci-ok gate) and as a prek hook locally, rejecting any modified, deleted or renamed migration relative to the branch’s fork point from master.

Mapping to the gateway

The control plane composes the normalized tables into the same shape as rolter_core::GatewayConfig (providers, routes, virtual keys and authorized MCP sessions), which the gateway turns into an immutable Snapshot.

Labels

A label is a short, visible fact attached to a provider, a provider group, a route or a model. Before #985 each such fact was built as a bespoke badge on one dashboard screen, which meant it existed nowhere else, could not be filtered on, and could not be added by an operator for their own purposes. This is the one primitive those facts are built on instead.

Two kinds, one table

customauto
written byan operatora rolter subsystem
meanswhatever the operator needs — eu-only, prod, owner:platform-teama fact rolter established
provenancenoneobserved_at and observation
editable through the APIyesno

They share a table because they are rendered and filtered together, and a screen that showed only one of them would be answering half the question. They do not share a write path: LabelRepo::create_custom and its siblings only ever touch source = 'custom', and upsert_auto / clear_auto only ever touch source = 'auto'. Nothing in the repository can turn one into the other.

Two consequences fall out of that, and both are deliberate:

  • An operator cannot forge an observation. The labels_provenance check constraint requires an auto row to carry observed_at and forbids a custom row from carrying it at all, and no HTTP handler writes auto. A hand-written label can look like anything except a probed verdict.
  • An auto label never silently overwrites a custom one. source is part of the uniqueness constraint, so (provider X, key "priced") can hold one of each. They are displayed side by side and marked; neither wins.

Editing or deleting an auto label answers 404, not 403. The where source = 'custom' clause on the update and delete statements is what enforces it, so there is no path — not even an internal one reachable from a handler — that edits an observation.

An auto label is a measurement, not a guarantee

observed_at is the point at which the fact was established and observation is what established it. Both are shown to operators. An auto label says this was true when we looked; it can be stale, and the UI must never present one as a promise about the next request.

The first producer is the pricing catalog: writing a model_prices row records priced on that model with the currency as its value, and deleting the row withdraws the label. It is written inside the same transaction as the price, because the moment the fact becomes true is that statement — a background job would only have to guess how stale it was allowed to be. New producers should follow the same rule: record the observation where the observed thing changes.

Subjects

subject_type is one of provider, provider_group, route or model, and subject_id is text rather than a uuid because a model is addressed by name while the other three are addressed by id.

That rules out a foreign key, so three after delete triggers sweep a subject’s labels when the subject goes. Without them a label would outlive the row it describes and — worse — be inherited by the next row issued that id.

Tenancy comes from the subject too: the table has no org_id, so LabelRepo::list_in_org joins back through providers, provider_groups and routes to decide what an org may see, and a write checks subject_in_org before it inserts. A label id belonging to another org answers 404 through this org’s path.

Scopes and the two API surfaces

SurfaceSubjectsCapability
/api/v1/orgs/{org_id}/labelsproviders, provider groups, routeslabel — org-scoped, read viewer, write admin
/api/v1/model-labelsmodelsmodel_label — deployment-wide, read any authenticated caller, write superadmin

The split follows where the subjects live. Providers, groups and routes belong to an org; models belong to the deployment-wide pricing catalog and to no org, so labelling one is a deployment-wide act and model_label mirrors model_price exactly.

label is its own capability rather than the subject’s, so an operator role can be allowed to annotate a provider without being allowed to re-point it.

Display and filter only, for now

This is why labels carry the experimental stability marker: the day a route selects on a label, a label stops being an annotation and becomes configuration the gateway consumes, which is a change of shape rather than an addition. See Stability markers for what that exempts labels from.

Labels are not read by the data plane. A route cannot yet select on one — a route that targeted eu-only would turn labels into config the gateway consumes, which is a materially larger change than displaying them.

The schema is shaped so that change needs no rewrite, and the labels_bump_config_version statement trigger is already in place. Bumping config_version on a label write costs a snapshot fetch today and buys the guarantee that the day a route does select on a label, propagation already works rather than silently serving stale config (see Config & hot reload).

RBAC & authentication

Two distinct auth surfaces:

1. Gateway (data plane) — virtual keys

Clients call /v1/* with a virtual key (Authorization: Bearer <key> or x-api-key). The gateway:

  • looks the key up in the current snapshot
  • checks the key is neither disabled nor past its expires_at
  • checks the key’s model allow-list (empty = all)
  • once the model resolves to a route, applies the route authorization contract below
  • (roadmap) enforces budgets and RPM/TPM limits for the key’s scope chain

Keys are stored as hashes; the presented key is compared in constant time (rolter_auth::verify_key).

The route authorization contract (#1485)

Access to a route must not depend on how the request body is encoded. Every data-plane endpoint that addresses a model therefore authorizes through the same two functions in crates/rolter-gateway/src/handlers.rs, and no endpoint re-implements a check inline:

GateRunsRefuses with
authorize_modelon the requested model id, before budgets, plugins and rate limits403 model_not_allowed
authorize_routeon the resolved route (named or provider-slug/model), before upstream403 with the code of the first gate

Before either gate sees a route, resolution (Snapshot::resolve_for) has already set aside every row outside the key’s tenancy: a route of another org, a route narrowed by advanced.visibility.project_only to a project the key was not minted in, and a provider or group address of another org. Such a row is a miss, so the request goes on to the builtin and the slug/model addresses as if it did not exist, and ends in the 404 model_not_found an unknown model gets (see One org never reaches another).

authorize_route checks, in order:

  1. tenancy and visibility (model_visible_to); refused as model_not_allowed. It runs first so a hidden route never reveals whether the other gates would also have refused. The tenancy half (RouteEntry::in_tenancy_of) repeats what resolution did and only matters where a route was reached some other way, such as a complexity tier or a stored response’s route; then advanced.visibility.allowed_team_ids / allowed_key_ids narrow the route to the teams and keys they list. A request with no key at all is refused by any non-empty visibility list.
  2. route policy — the access-profile policy of the key’s creator (KeyMeta::route_permitted, #791); refused as route_not_allowed.
  3. provider allow-list — at least one target on the route (classic pool or any variant) is a provider the key may use (key_allows_route); refused as provider_not_allowed.

The per-target provider filter in pick_untried still runs during balancing; the route gate only guarantees that at least one target is reachable, so the caller gets an explicit 403 rather than a failed target selection.

The callers are the JSON pipeline (proxy: chat, completions, responses, messages, embeddings, rerank, image generation, audio speech), the multipart pipeline (proxy_multipart: audio transcriptions and translations) and the Realtime upgrade (realtime::realtime), which refuses before the WebSocket handshake completes and before an upstream socket is dialled. The complexity tier selector and GET /v1/models use the same authorize_route, so a tier never upgrades a request onto a route its key could not address directly, and the listing only shows routes the key could call. Before #1485 the multipart path checked only the model allow-list and the Realtime upgrade skipped visibility, so a hidden or policy-denied route was reachable through an audio upload or a socket. The HTTP regressions for every gate on every pipeline live in crates/rolter-gateway/tests/integration.rs (audio_uploads_*, realtime_upgrade_enforces_route_visibility).

visibility.allowed_user_ids and visibility.minimum_role are not enforced on the data plane: a virtual key carries no user identity or role. They remain control-plane concerns.

The Responses lifecycle calls (GET/DELETE /v1/responses/{id}, POST /v1/responses/{id}/cancel, GET /v1/responses/{id}/input_items) address a stored response rather than a model, so they carry no route to resolve. The registry entry records both the model the caller named and the route that served it (ResponseRoute::route: the configured route name, the tier route a complexity policy moved the request onto, or the provider-slug/model address itself). authorize_lifecycle re-runs the contract against the current snapshot on every call (#1779):

  1. authorize_model on the recorded model id
  2. the recorded route is resolved as the request path would resolve it (named route first, then provider-slug/model addressing), and authorize_route runs on it
  3. the key must still allow the one provider holding the response; refused as provider_not_allowed, since the call can only go to that provider

It runs after the tenant-scoped registry lookup, so another key still gets the uniform 404 response_not_found and learns nothing; only the response’s owner can see these 403s. Without it, access revoked after creation (visibility, route policy, a narrowed key) would keep working for the registry TTL, which is 24 hours by default.

A removed route fails closed. When the recorded route no longer resolves, the call is refused with 403 route_not_allowed (“no longer configured”). With the route gone there is nothing left to evaluate visibility and route policy against. Allowing the call would let a deleted route keep serving provider-credentialed calls until the entry expires, and deleting a route is often exactly how an operator withdraws access. The cost is that a response created on a route that was then renamed or removed can no longer be managed through the gateway; recreating the route restores access. HTTP regressions: response_lifecycle_rechecks_route_authorization_after_revocation and response_lifecycle_reauthorizes_pinned_addresses.

A new endpoint that resolves a route must call both gates. A Realtime session also meets the budget, unpriced_policy and rate-limit admission of the HTTP path and is metered per response turn; see Realtime metering. Its key is checked once, at the upgrade, so revoking a key does not yet end a session already open (#1881).

One org never reaches another (#1844, #1845)

Routes, providers and provider groups live in the store under an org, and a route under a project as well. The snapshot carries that as tenancy (rolter_core::Tenancy) on every ModelRoute, ProviderConfig and ProviderGroupConfig it loads from the store; rows from a gateway-only config file carry none. Tenancy::admits decides whether a key may use a row:

rowkey minted in the store (has an org)key from the gateway’s config file (no org)
no tenancy (config file)admittedadmitted
tenancy of the key’s own orgadmittedadmitted
tenancy of another orgrefusedadmitted

Every way of addressing a model resolves through Snapshot::resolve_for, which applies the rule before anything else: a named route; then provider-slug/model, whose synthetic route takes the provider’s tenancy; then group-slug/model, whose synthetic route takes the group’s. A row the key’s org may not use is a miss, not a refusal. Another org’s route named edge/gpt-4o therefore never shadows this org’s own edge provider, and another org’s route named fake-llm never hides the builtin. The key gets the 404 model_not_found a model nobody configured gets, so the answer does not confirm the row exists. GET /v1/models lists providers, routes and groups through the same rule, so a key neither lists nor calls another org’s rows. Before #1844 any key could address any org’s provider by slug and spend that org’s credential.

advanced.visibility.project_only narrows a route further, to keys minted in the project the route lives in, and a key of another project gets the same miss. It is off by default (a route is visible to its whole org), and an admin turns it on as This project under the model’s Access & permissions. A key from the gateway’s config file is not narrowed. It narrows the named route only: the provider or group behind it stays reachable org-wide as provider-slug/model or group-slug/model, so confining a provider to one project takes the key’s providers allow-list (#1919).

The write path keeps the snapshot inside that rule:

  • A route target or a provider-group member must name a provider in the route’s (or group’s) own org. Another org’s provider id gets the 404 an unknown id gets, so the refusal does not confirm that the provider exists. The snapshot loader also drops, with a warning, any cross-org target or member written before the guard existed, rather than serve it.
  • Route names and provider names are unique across the whole deployment, because the gateway indexes them that way and Config::validate refuses a snapshot holding a duplicate, which would stop config propagation for every org at once.
  • Provider slugs and provider-group slugs form one address namespace across the deployment, bootstrap-file rows included, because the gateway resolves provider-slug/model and group-slug/model from it. Config::validate does not check slugs; a second holder would silently take the address instead, and a group whose slug a provider holds would drop out of routing. So a provider slug is refused when any provider or group holds it, and the same for a group slug, on create and on a slug change. The gateway indexes first-wins in file order, bootstrap rows before database rows, so a slug two rows still share from before the guard resolves the same way on every build.
  • A route name may contain / (Qwen/Qwen2.5-7B), but not as the address of another org’s or a bootstrap-file provider or group (edge/… while another org holds edge), and a database route may not be named fake-llm. The gateway already treats those routes as absent for other orgs’ keys; the refusal protects keys from a config file, which carry no org and would reach the route instead of the address. It runs one way only: a provider or group created later with the slug of another org’s existing slug/… route is accepted, because refusing it would let any org reserve slugs by naming routes after them. That org’s own keys get their address; org-less keys (and anonymous callers with require_auth off) resolve the named route first and reach the other org’s route. An operator may still replace the builtin: a bootstrap-file route, a [[models.default]] or a rolter-seed --import named fake-llm shadows it.
  • Every refusal is a 409 (“… is already in use in this deployment; choose another”) that never says which org holds the name. rolter-seed --import refuses the same collisions, and the startup seeders ([[providers.default]], [[provider_groups.default]], [[models.default]]) skip a colliding default with a warning instead of writing it. Per-org namespaces are #1857.

The dashboard never sees tenancy: redact_config_for_dashboard clears it along with the credentials, and drops the database key records, which name every key’s org, team, project and creator. The unauthenticated GET /api/v1/config still lists every org’s providers, routes and groups; whether it may describe that topology at all is #1840.

One org never reaches another (#1844, #1845)

Routes, providers and provider groups live in the store under an org, and a route under a project as well. The snapshot carries that as tenancy (rolter_core::Tenancy) on every ModelRoute, ProviderConfig and ProviderGroupConfig it loads from the store; rows from a gateway-only config file carry none. Tenancy::admits decides whether a key may use a row:

rowkey minted in the store (has an org)key from the gateway’s config file (no org)
no tenancy (config file)admittedadmitted
tenancy of the key’s own orgadmittedadmitted
tenancy of another orgrefusedadmitted

Every way of addressing a model resolves through Snapshot::resolve_for, which applies the rule before anything else: a named route; then provider-slug/model, whose synthetic route takes the provider’s tenancy; then group-slug/model, whose synthetic route takes the group’s. A row the key’s org may not use is a miss, not a refusal. Another org’s route named edge/gpt-4o therefore never shadows this org’s own edge provider, and another org’s route named fake-llm never hides the builtin. The key gets the 404 model_not_found a model nobody configured gets, so the answer does not confirm the row exists. GET /v1/models lists providers, routes and groups through the same rule, so a key neither lists nor calls another org’s rows. Before #1844 any key could address any org’s provider by slug and spend that org’s credential.

advanced.visibility.project_only narrows a route further, to keys minted in the project the route lives in, and a key of another project gets the same miss. It is off by default (a route is visible to its whole org), and an admin turns it on as This project under the model’s Access & permissions. A key from the gateway’s config file is not narrowed. It narrows the named route only: the provider or group behind it stays reachable org-wide as provider-slug/model or group-slug/model, so confining a provider to one project takes the key’s providers allow-list (#1919).

The write path keeps the snapshot inside that rule:

  • A route target or a provider-group member must name a provider in the route’s (or group’s) own org. Another org’s provider id gets the 404 an unknown id gets, so the refusal does not confirm that the provider exists. The snapshot loader also drops, with a warning, any cross-org target or member written before the guard existed, rather than serve it.
  • Route names and provider names are unique across the whole deployment, because the gateway indexes them that way and Config::validate refuses a snapshot holding a duplicate, which would stop config propagation for every org at once.
  • Provider slugs and provider-group slugs form one address namespace across the deployment, bootstrap-file rows included, because the gateway resolves provider-slug/model and group-slug/model from it. Config::validate does not check slugs; a second holder would silently take the address instead, and a group whose slug a provider holds would drop out of routing. So a provider slug is refused when any provider or group holds it, and the same for a group slug, on create and on a slug change. The gateway indexes first-wins in file order, bootstrap rows before database rows, so a slug two rows still share from before the guard resolves the same way on every build.
  • A route name may contain / (Qwen/Qwen2.5-7B), but not as the address of another org’s or a bootstrap-file provider or group (edge/… while another org holds edge), and a database route may not be named fake-llm. The gateway already treats those routes as absent for other orgs’ keys; the refusal protects keys from a config file, which carry no org and would reach the route instead of the address. It runs one way only: a provider or group created later with the slug of another org’s existing slug/… route is accepted, because refusing it would let any org reserve slugs by naming routes after them. That org’s own keys get their address; org-less keys (and anonymous callers with require_auth off) resolve the named route first and reach the other org’s route. An operator may still replace the builtin: a bootstrap-file route, a [[models.default]] or a rolter-seed --import named fake-llm shadows it.
  • Every refusal is a 409 (“… is already in use in this deployment; choose another”) that never says which org holds the name. rolter-seed --import refuses the same collisions, and the startup seeders ([[providers.default]], [[provider_groups.default]], [[models.default]]) skip a colliding default with a warning instead of writing it. Per-org namespaces are #1857.

The dashboard never sees tenancy: redact_config_for_dashboard clears it along with the credentials, and drops the database key records, which name every key’s org, team, project and creator. The unauthenticated GET /api/v1/config still lists every org’s providers, routes and groups; whether it may describe that topology at all is #1840.

Empty key sets

An empty effective key set does not mean “auth disabled” on a managed deployment. A gateway started with --snapshot-url (its config comes from the control plane) fails closed: with no virtual keys in the snapshot every /v1/* request gets 401. This keeps revoking the last key a lock-down rather than an accidental opening of the whole data plane.

A gateway running from a static bootstrap config with no keys stays keyless, so local fake-llm development needs no setup.

Key lifetime (#945)

virtual_keys.expires_at has always been read by the data plane — KeyMeta::is_valid refuses a key at or after that instant — but until #945 the mint path never wrote a value, so every key created through the dashboard or the API was immortal. The mint request now carries expires_in_days and the control plane turns it into an instant, rather than accepting one from the caller: a client with a wrong clock cannot mint a key that outlives what the operator chose. Omitting the field is the only way to ask for a key that never expires, and 0 is refused because it reads as “no expiry” while meaning “already expired”.

The same request requires a non-blank name. The plaintext secret is returned exactly once, so an unnamed key cannot be told apart from its siblings afterwards; the rule is enforced in the API, not only in the form, and applies equally to the admin (/api/v1/projects/{id}/virtual-keys) and self-service (/api/v1/me/projects/{id}/virtual-keys) paths.

Rotation replaces a secret without renewing the decision: the fresh key inherits the old one’s expires_at, and its purpose.

Personal keys follow their creator (#1841)

A key minted through the self-service routes (/api/v1/me/...) records its creator in virtual_keys.created_by; a key an admin mints for a project (/api/v1/projects/{id}/virtual-keys) records none. load_virtual_keys serves a personal key only while its creator:

  • is active (users.deactivated_at is null), and
  • still holds a role that reaches the key’s project — a membership at the project, its team or its org, or an access-profile role there, assigned to them or to a team they belong to — or is a superadmin.

Deactivating a leaver, through SCIM or PUT /api/v1/users/{id}, or removing their last such role therefore takes their keys off every gateway at the next snapshot. Reactivating them brings the same keys back, since nothing about the keys changed. A shared key is the project’s and never depends on who minted it, so offboarding cannot take an application down.

Migration 0075 makes this propagate. The snapshot now reads users and access_profile_roles, so both carry a bump_config_version() trigger (on users, only for deactivated_at and is_superadmin). Deleting the account disables its personal keys in the same statement, through a before delete trigger: created_by is on delete set null, which would otherwise turn them into shared keys, served without the access-profile policy that followed their owner. The audit row for a deactivation, reactivation or deletion records personal_keys, the number of keys it suspended, restored or disabled.

The playground key is scoped by the server

POST /api/v1/me/projects/{id}/playground-key mints a key for the dashboard’s own Playground, and it deliberately takes no request body (#1640). Both of the things a body would carry are the reasons this endpoint exists:

  • Reach. The mint paths above take models from the caller, which is right for a key an operator is configuring and wrong for this one: a key the client scopes cannot be a key that “cannot address a model the user could not already address”, because the client can ask for everything. The list is computed here from the routes configured in the project being minted against, and written out explicitly — an empty models array means every model, so a project with no routes is a 400 rather than a key with the widest possible reach.
  • Lifetime. expires_in_days has a floor of one day. A playground key lives PLAYGROUND_KEY_TTL_MINUTES (30) and the dashboard asks for a fresh one, so a credential does not outlive the tab holding it.

The row carries purpose = 'playground' so the Keys screen can say what it is rather than leaving a reader to infer it from a short expiry. The list is resolved once, at mint time: a route added afterwards is outside that key’s reach, which makes it a snapshot of what the caller could reach rather than a standing grant.

The dashboard calls this once per project as the Playground opens, and once more per Mint key / Renew key — never in a loop, since a refusal (a routeless project answers 400, no session answers 401) is a state the operator has to act on rather than one a retry can clear. The automatic call waits for my_virtual_key:create from /api/v1/rbac/effective and is not made on an explicit refusal, so a viewer is not sent into a 403 on arrival. The endpoint takes no body, so 400 is the one client error it gives, and the Playground reads that status as “this project routes nothing”: it explains the precondition, links Routing Rules and offers no retry (#2061). With no key and no mint due, the Playground asks GET /gw/v1/models once without a key; a gateway no control plane manages, holding no keys, answers it, and the screen then sends without a key rather than holding back its Send buttons. Both the Virtual Keys screen and the account’s own key list label a purpose = 'playground' row, so a half-hour expiry reads as the design rather than as somebody’s mistake.

The mint answers as soon as the row is written, but a gateway only learns about the key on its next snapshot poll (ROLTER_SNAPSHOT_POLL_SECS, 5 by default), so the Playground’s first GET /gw/v1/models with a fresh key answers 401 (#1853). The dashboard waits that out rather than treating it as a verdict: awaitingMintedKey in ui/src/lib/gateway.ts retries a minted key’s 401 with doubling backoff for about two poll intervals (10 s), and only then falls back to the control plane’s route list. A pasted key gets one attempt, and any status other than 401 falls back at once, since neither is something waiting can fix. The fallback drops the routes /api/v1/config/problems reports as omitted from the snapshot, and nothing is preselected from it until that list has answered, so the chat column cannot open on a route the gateway never received.

Override either default with server.require_auth:

valuebehaviour on an empty key set
truealways deny (401)
falsealways allow the keyless path
unset (default)managed → deny, static local config → allow

2. Control plane (dashboard) — users + roles

Human users authenticate to the control plane. Two providers ship today: local accounts (argon2id password hashes, rolter_control::auth) and OAuth2/OIDC SSO (rolter_control::sso, authorization-code flow with PKCE, JWKS-verified id tokens, IdP group → role mapping). RBAC roles:

  • admin — full control within scope (manage providers, routes, keys, members, budgets)
  • member — create/edit routes and keys within scope
  • viewer — read-only (dashboards, logs)

Roles are granted via memberships at an org / team / project scope. Permission checks resolve the most specific membership for the target resource.

Sessions are stateful rows (sessions, peppered token digest), so revocation is a delete. Deactivation, deletion, SCIM deprovisioning and a break-glass factor reset remove every session the account holds. A password set through PUT /api/v1/users/{id} does the same, except for the session that sent the request, so a superadmin resetting their own password stays signed in where they did it (SessionRepo::delete_for_user_except, #1936). The user.update audit detail carries password_changed and sessions_revoked.

flowchart LR
  U[User] -->|member of| Scope[org / team / project]
  Scope -->|role| Caps[admin / member / viewer]
  Caps --> Action{allowed?}

The capability table is the only source of truth

CAPABILITIES in crates/rolter-control/src/rbac_matrix.rs records, for every resource, what each of read / create / update / delete takes — a minimum scoped role, superadmin-only, or that the resource has no such action at all. Both the published matrix and the guard read it, so they cannot drift.

A guarded handler names a (resource, action) pair instead of a role:

#![allow(unused)]
fn main() {
authorize(&state, &principal, ScopeChain::org(org_id), cap!("provider", Create)).await?;
// deployment-wide, no scope to hold a role in:
authorize_superadmin(&principal, superadmin_cap!("feature_flags", Update))?;
}

cap! resolves the requirement through a const fn, so naming a resource the table does not define — or an action it marks as unsupported — is a compile error, and superadmin_cap! additionally fails to compile unless the table says the pair is superadmin-only. No handler names a Role; unit tests in rbac_matrix.rs scan every control-plane module to keep it that way, check the module list against src/ so a new file cannot slip past, and assert that every row in the table is claimed by at least one guard.

Two read-only endpoints publish that table, so a dashboard never assembles a permission matrix of its own:

  • GET /api/v1/rbac/matrix — every role and, per resource, the minimum role each action takes (or that the action is superadmin-only, or unsupported entirely). Any authenticated caller may read it; it describes rules, not anyone’s access.
  • GET /api/v1/rbac/effective?org_id=&team_id=&project_id= — the calling principal’s resolved role at that scope chain and the concrete resource:action pairs they may perform, evaluated from their memberships.

effective is advisory to the client and authoritative only on the server: a caller that ignores it and issues the request anyway gets the same 403. Scope precedence is unchanged — a project-scoped grant authorizes that project, not the whole org.

Read access is a viewer’s and mutations are an admin’s, with three deliberate exceptions:

  • deployment-wide policy (feature flags, runtime/compatibility/adaptive policy, logging settings, cluster nodes, security settings, alerting, MCP tool-call logs) has no tenancy scope to be a member of, so it is superadmin-only;
  • global account lifecycle — creating an org, editing or deleting a user account, and the model/pricing catalog — reaches across orgs, so it is superadmin-only too, while inviting a user into an org stays an org admin’s;
  • a user’s own things — minting a virtual key for yourself takes member (a viewer cannot), and revoking your own MCP OAuth grant or session takes only a viewer membership plus ownership, which the handler checks after the guard.

Listing the pricing catalog (GET /api/v1/model-prices) and the effective model list (GET /api/v1/models) is every authenticated caller’s, with no membership anywhere. That is a third authority alongside a scoped role and superadmin, and the table names it rather than implying a role floor: both are deployment-wide catalogs of upstream capability and list price that carry no tenant’s data, and deployment is not a scope a membership can be held at, so a viewer floor there would have described a bar nobody could clear. GET /api/v1/rbac/matrix reports those cells as authenticated_only. The effective model list is still filtered per caller by the access-profile model policy (#534), so what a caller sees remains theirs alone.

Every cell in the table is now backed by the guard.

Listings answer with what the caller reaches (#1846, #1850)

A list endpoint guarded at its parent scope used to refuse the whole list to anyone without a role at that parent, so a team or project member could not find their own team or project, and a team admin could not see the people in their team. The list endpoints now answer with the rows the caller may read. ScopeFilter (rbac.rs) loads the caller’s memberships and access-profile grants once and decides each row by the rule authorize applies. “A role” below means one that meets the endpoint’s own floor in CAPABILITIES — viewer for every list here except invitations, which take admin:

endpointa role at the parenta role only below it
GET /api/v1/orgssuperadmin: every orgthe orgs they hold a role anywhere inside
GET /api/v1/orgs/{org_id}/teamsevery teamthe teams they hold a role in or inside
GET /api/v1/teams/{team_id}/projectsevery projectthe projects they hold a role in
GET /api/v1/orgs/{org_id}/projectsevery projectthe projects they read through a team or project role
GET /api/v1/orgs/{org_id}/users, /membershipseveryonethe people and memberships in the teams and projects they read
GET /api/v1/orgs/{org_id}/invitationsevery invitationthe invitations into the teams and projects they administer

A caller with no role anywhere inside the org still gets 403, and a plain signed-in account learns no org it does not belong to. GET /api/v1/rbac/matrix?org_id= follows the same rule for the org’s custom roles: the dashboard reads the table to say which role a disabled control needs, so a project member who could not read it saw every refusal explained as “your role does not permit this” and an empty Roles & Permissions screen. Revoking an invitation is authorized at the invitation’s own scope (its project, else its team, else the org), so a team admin can revoke an invitation they could send. GET /api/v1/auth/me returns each membership with scope_org_id and scope_team_id filled in, and the dashboard’s scope switcher uses them to land a project member on their own project instead of the first team of the first org.

A handler that starts from the session (CurrentUser) and then authorizes builds its principal with Principal::for_user, which turns is_superadmin into Principal::Superadmin. Before #1847 the self-service key routes built a plain user principal, so a superadmin with no membership — the operator seeded on a fresh deployment — could not mint a personal key or open the Playground.

Account events in an org’s audit log (#1854)

Account events — sign-ins and failed sign-ins, second-factor changes, break-glass resets, account edits and deletions — belong to a person rather than an org, so they are written with no org. GET /api/v1/orgs/{org_id}/audit-log returns them for the org’s own people: a row with no org is included when its actor, or its target user, holds a role in the org, its teams or its projects. user.delete is written once per org the account belonged to, because its memberships are deleted with it and nothing would tie an org-less row back to those orgs afterwards. Rows no org can claim — a superadmin’s own sign-ins, attempts against an unregistered address — are not readable through the API yet; the deployment-wide read is #1858. See security: who reads account events.

Custom roles and access profiles

The three built-in roles are a floor, not the whole rule set. An org may define custom roles: a base role plus a set of explicit (resource, action) grants drawn from the same CAPABILITIES table the guard reads. A grant can only widen — a custom role never takes away what its base role already allows, so the built-in roles keep behaving exactly as before and nothing has to be migrated.

Custom roles are not assigned to people directly. They are composed into an access profile, which names each role together with the org, team or project it applies at, and the profile is then assigned to users or teams. One profile can therefore say “auditor at the org, deploy admin on this one project” and be reused across an organization instead of re-granted per person.

flowchart LR
  P[Access profile] -->|role @ scope| CR[Custom role]
  CR -->|base| Built[admin / member / viewer]
  CR -->|grants| Pairs["resource:action pairs"]
  P -->|assigned to| Who[user or team]
  P -->|optional| Pol[model / route policy]

Evaluation order inside authorize is unchanged for the common case: memberships resolve first, and the configurable half is consulted only if the built-in answer was “no”. That keeps a plain deployment on exactly the old code path, and makes a custom grant strictly additive.

A profile may also carry a model and route policy — allow and deny lists over the models and routes its holders may reach. Deny wins over allow. Where a user holds several profiles the lists are unioned rather than intersected, since a second profile must never reduce access; one consequence is load-bearing: a profile with no model restriction at all makes the merged allow-list unrestricted, because that profile already permitted everything on its own.

That policy is published on GET /api/v1/rbac/effective as model_policy, and since #791 it is also enforced by the data plane. The bridge is the virtual key: a policy belongs to a person, but a request carries a credential, so the control plane resolves each key owner’s merged policy when it builds /internal/snapshot and publishes it on the key record. The gateway then applies it in KeyMeta::model_permitted and KeyMeta::route_permitted, alongside the key’s own model allow-list.

Both must permit a model, deliberately. The key list is what the key’s creator scoped that credential to; the policy is what an operator decided the person may reach at all. Neither can widen the other, so a key naming a model its owner is denied stays denied.

The shape, the merge rule and the allow/deny matching all live in rolter_core::ModelPolicy, which the control plane, the store and the gateway share — two implementations of “deny wins” free to drift apart would be a security bug.

Enforcement keys on the virtual key’s created_by. A key with no owner — admin-created and config-defined keys — carries no policy, because there is no person whose profiles could apply; restricting those is still the key’s own model list.

Because the gateway now reads them, four of these tables do carry a bump_config_version() trigger: access_profile_policies, access_profile_assignments, access_profiles and memberships (a profile assigned to a team reaches every member, so a membership change alters someone’s effective policy with no profile row changing). Since #1841 access_profile_roles does too, because a profile role is one of the ways a personal key’s creator still reaches the key’s project. custom_roles and custom_role_grants still do not and still must not: they decide control-plane authorization, which is evaluated per request against the live database, so there remains nothing to propagate and a trigger would only wake the fleet for a change it cannot observe. See ADR-0023 for why the policy is resolved at snapshot time rather than when a key is minted.

Changing one is safe by construction:

  • deleting a custom role that a profile still references returns 409 rather than silently emptying the profile’s composition; detach it first;
  • deleting a profile does cascade its own assignments, since the assignment has no meaning without it;
  • every create, update and delete on a role, a profile, an assignment or a policy is written to audit_log with the before/after, because all of them change what real people can do.

GET /api/v1/rbac/matrix?org_id= returns the org’s custom roles alongside the built-in ones, so the dashboard’s matrix is API-backed and updates after any change instead of holding state of its own.

The dashboard edits both halves (#1184). ui/src/pages/Rbac.tsx carries the matrix on one tab and the org’s custom roles on another, with a resource x action grant grid built from the same resources[] the matrix returns — never a list held in the dashboard, so the grid can only offer pairs this build defines. A pair the resource does not have renders as a dash and a superadmin-only pair as a disabled checkbox, and grants naming a resource the build no longer defines ride through an edit untouched, since PUT replaces the grant set wholesale. ui/src/pages/AccessProfiles.tsx reads GET /api/v1/access-profiles/{id} per profile — the only call that answers roles, assignments and policy together — and sends the composition and the policy in the one request that saves the profile.

Endpoints:

  • GET/POST /api/v1/orgs/{org_id}/custom-roles, GET/PUT/DELETE /api/v1/custom-roles/{id}
  • GET/POST /api/v1/orgs/{org_id}/access-profiles, GET/PUT/DELETE /api/v1/access-profiles/{id}
  • GET/POST /api/v1/access-profiles/{id}/assignments, DELETE /api/v1/access-profile-assignments/{id}
  • PUT /api/v1/access-profiles/{id}/policy

Identity providers (ROL-35)

Local login and SSO both end at the same place: a verified identity that gets turned into a session and reconciled memberships. That shared shape is rolter_auth::IdentityProvider — an async trait with one method, resolve(Credential) -> Result<Identity, IdentityError> — so a new provider only has to prove who someone is; everything downstream (session issuance, group → role reconciliation, audit logging) is unchanged.

  • rolter_control::auth::LocalIdentityProvider verifies a Credential::Password, preserving the original handler’s timing-safety property: every rejection path (unknown account, deactivated, sso-only, password login disabled by org policy, wrong password) still runs exactly one argon2 verification, so response time reveals nothing about which case applied.
  • rolter_control::sso::OidcIdentityProvider verifies a Credential::AuthorizationCode, wrapping the existing code-exchange and JWKS id-token verification.
  • Concrete providers live in rolter-control (next to the sqlx/reqwest they need), not in rolter-auth, which only defines the trait and stays free of those dependencies.

Roadmap

  • LDAP — bind + group mapping for enterprise directories (#241), the next provider to implement IdentityProvider.
  • JWT service auth and short-lived tokens.
  • Audit log surfaced in the UI.
  • Optional constant-time map / pepper for virtual-key lookup hardening.

Invitations

Invitations are how a rolter deployment onboards people without an identity provider. An admin mints a one-time link, the invitee opens it and chooses their own password, and the role the invitation carries is granted on acceptance.

This is deliberately not the same as POST /api/v1/orgs/{org_id}/users, which creates an account with a password the admin picked. That is right for seeding and for service accounts, and wrong for onboarding a colleague: it leaves someone else holding their credential — usually in a chat log.

The flow

  1. POST /api/v1/orgs/{org_id}/invitations (admin at the target scope) returns the invitation, the token, and a ready-made accept_url. The token is shown exactly once; only its peppered SHA-256 digest is stored, the same treatment sessions and virtual keys get, so a database dump alone yields no usable link.
  2. GET /api/v1/invitations/accept/{token} — unauthenticated preview returning the org name, the invited email, the role and the expiry. Nothing else: a link that leaked should not also leak a directory.
  3. POST /api/v1/invitations/accept/{token}/accept with the invitee’s chosen password creates the account, grants the membership, and returns a live session so they land signed in.
  4. DELETE /api/v1/invitations/{id} revokes a pending invitation.

Links expire after seven days.

Failing closed

Expired, revoked, already-accepted and simply wrong tokens all return the same 401. The caller learns whether their link works, not which of those it is.

Acceptance is single-use even under a race: the claim is an UPDATE … WHERE accepted_at IS NULL, which either affects one row or tells the loser they lost — before any account or membership is created.

One unaccepted, unrevoked invitation exists per email per org, enforced by a partial unique index (invitations_live_email_idx). The index does not look at expires_at, so an invitation that expired unaccepted still holds its address until it is revoked. InvitationRepo::create is a plain insert, so inviting an address that already holds one fails on that index and answers 500 with the database’s message, not the replacement the migration comment describes (#2324). The dashboard lists expired invitations for this reason, so they can be revoked.

Who may invite, list and revoke

Creating is authorized at the scope the invitation grants: scope_type and scope_id name an org, a team or a project, and the caller needs invitation create there or above, the same admin bar as granting a role directly. invited_by records the caller, and is null for the admin token.

GET /api/v1/orgs/{org_id}/invitations answers every invitation of the org, accepted and revoked ones included. An org admin gets all of them; below the org the answer is filtered to the teams and projects the caller administers (#1850), and a caller who reaches the org through neither gets 403. Revoking is authorized at the invitation’s own scope, and only touches an unaccepted invitation: an accepted one answers 404.

The dashboard

Governance → Users → Invite user (ui/src/pages/Users.tsx) picks the scope with the shared OrgScopePicker and posts scope_type and scope_id. The role field is named for the scope (pages.users.orgRole, teamRole, projectRole) and its options are labelled through shell.roles.*. The password method creates the account through POST /orgs/{org_id}/users, which has no scope, so the picker is locked to the org while it is chosen. The link dialog copies with the shared CopyButton: a clipboard the browser withholds (a plain-http dashboard) is reported as a failed copy, and the dialog stays open with the link.

PendingInvitations, below the users table, keeps the rows with neither accepted_at nor revoked_at, including expired ones (marked), and revokes through ConfirmDialog (invitation-revoke), gated on invitation:delete. The section waits for the capability answer and is absent on an explicit invitation:read refusal or a 403 from the list, so a viewer who may open the screen never meets a forbidden error for it. “Sent by” is the users-list entry for invited_by, since the row carries the id only (#2325).

Existing accounts

Acceptance adopts an account that already exists under the invited email rather than forking a second row for the same person. Someone may already hold a login in another org, or have arrived through SSO first.

  • An account with a password keeps it. An invite link is not a password reset.
  • An SSO-only account (no password) gains the password it was invited to set.
  • A deactivated account cannot be revived by an invitation (403).

Relationship to single sign-on

Invitations and single sign-on co-exist; neither requires the other.

The membership an acceptance grants carries source = 'manual', and an SSO login only ever reconciles source = 'sso' rows. So an invited role survives every later IdP login, while roles that came from IdP groups are recomputed each time. A deployment can run invitations only, SSO only, or both at once.

Configuration

SettingWhereNotes
ROLTER_PUBLIC_URLenvthe accept_url is built from it; set it correctly behind a proxy or the link you hand out points at localhost. Read once at startup, so a change needs a restart
ROLTER_SESSION_PEPPERenvpeppers the stored token digest, same as session tokens

rolter does not send the email itself: it returns the link and lets the operator deliver it however their organization already delivers things. That keeps the control plane free of an SMTP dependency, which matters for air-gapped deployments.

  • Single sign-on (OIDC) — the IdP path, and how membership sources keep the two from fighting
  • RBAC & auth — roles and scopes
  • SCIM provisioning — IdP-driven lifecycle for deployments that want accounts created without anyone clicking a link

Single sign-on (OIDC)

rolter can authenticate operators against an OIDC identity provider — Keycloak, Okta, Entra ID, Auth0, Google Workspace — and turn the groups that provider reports into rolter roles.

Single sign-on is optional and additive. A deployment that never registers a provider behaves exactly as it did before: local accounts, passwords, and operator-granted roles. Nothing about SSO is reachable, and the login screen never mentions it.

The three supported deployments

DeploymentConfigurationLogin screen
Local accounts onlyno sso_providers rowsemail + password
SSO onlya provider, and allow_password_login = false on the orgone “Continue with …” button
Botha provider, and password login left enabledpassword form and the button

The dashboard asks GET /api/v1/auth/methods — the one unauthenticated endpoint in this area — and renders whichever of the three it is told. That endpoint returns provider names, slugs and start URLs only; all of which are already visible in the login URL, and none of which are secret.

The flow

Authorization code with PKCE, no implicit grant, no client-side tokens:

  1. GET /auth/sso/{slug}/start mints a state, a nonce and a PKCE verifier, stores them in sso_login_states, and redirects to the provider’s authorization_endpoint.
  2. The provider redirects back to GET /auth/sso/{slug}/callback.
  3. The callback consumes the state row (DELETE … RETURNING), so a replayed code + state pair finds nothing and is refused. States older than ten minutes are treated as absent and swept.
  4. The code is exchanged at the token_endpoint with the PKCE verifier and the sealed client secret.
  5. The id token is verified against the provider’s JWKS: signature by kid, issuer, audience (client_id), expiry, and the nonce from step 1.

Rules that hold on every path:

  • The redirect URI comes from configuration (ROLTER_PUBLIC_URL), never from the request, so an attacker cannot point the callback elsewhere.
  • Only asymmetric algorithms are accepted (RS256/384/512, ES256/384, PS256). HS256 and none are rejected — a symmetric id token signed with a value the attacker may know is not evidence of anything.
  • The discovery document’s issuer must equal the configured issuer.
  • A failed token exchange reports the HTTP status only. The provider’s error body can echo the client secret back, and that must not reach a log.

Groups become memberships

An operator maps an IdP group to a role at an org, team or project scope:

POST /api/v1/sso-providers/{id}/group-mappings
{"group_name": "platform", "role": "admin", "team_id": "…"}

The groups claim is read in every shape providers actually send it — a JSON array, a single string, or a space-separated list — and Keycloak’s leading / is stripped, so operators map the group name they see in the IdP’s own UI. The claim name is configurable per provider (group_claim, default groups).

If no mapping matches, the provider’s default_role applies. If there is no default_role either, the login is refused: SSO authenticates, it does not implicitly authorize.

Manual and SSO grants co-exist

Every membership records where it came from:

  • source = 'manual' — an invitation, the admin API, the seed command.
  • source = 'sso' — an IdP group mapping.

Each login reconciles only the sso rows inside that provider’s org. So:

  • A role an operator granted by hand survives every SSO login, forever.
  • Dropping a user from a mapped group revokes the role that group granted, on their next login — including the login that is then refused for having no grants left. That is how deprovisioning through the IdP works without SCIM.
  • Another org’s SSO grants are untouched; they belong to that org’s provider.

An account is adopted by verified email: someone invited last month who now arrives through the IdP keeps the same user row, the same virtual keys and the same manual roles. SSO-created accounts get no password — an SSO identity must not silently gain a second, weaker credential.

Org login policy

PUT /api/v1/orgs/{org_id}/auth-policy (org admin) sets two flags:

  • allow_password_login — when false, members of this org cannot use the password form.
  • allow_sso — when false, callbacks for this org’s providers are refused without deleting the provider rows, so an IdP can be cut off in one request. An account a provider created has no password, so while this is off those members cannot sign in at all, whatever allow_password_login says. The dashboard confirms the change when the org has an enabled provider (#2326).

Two guard rails, both returning 409:

  • Both flags off is not a policy, it is an outage.
  • Password login cannot be disabled before an enabled provider exists.

And one exemption: a superadmin can always log in with a password, whatever the policy says. A mistyped issuer or an IdP outage would otherwise lock the deployment out with no way back in. That exemption is the reason the flag is safe to turn on at all; keep the superadmin’s password strong and stored somewhere the IdP does not gate.

Configuration

SettingWhereNotes
ROLTER_PUBLIC_URLenvthe control plane’s externally reachable base URL; the redirect URI is derived from it. Defaults to http://localhost:4001, and is read once at startup so a change needs a restart
ROLTER_KEKenvrequired to store or read a client secret; the secret is sealed with AES-256-GCM exactly like provider credentials
ROLTER_SESSION_PEPPERenvsession tokens are stored as peppered digests, same as local logins

Register the redirect URI "$ROLTER_PUBLIC_URL/auth/sso/{slug}/callback" with the identity provider.

A provider’s slug is in that URI, so it is fixed at creation (PUT does not accept one) and has one rule, ^[a-z0-9][a-z0-9-]{0,62}$: lowercase letters, digits and hyphens, starting with a letter or digit, at most 63 characters. It is enforced in three places that have to agree (#2304):

  • the sso_providers_slug_charset check constraint in 0047_sso_providers.sql, which is the rule itself;
  • validate_slug() in sso.rs, which create_provider runs before the insert so a bad slug is a 400 that states the rule and not a store error carrying the constraint name. The slug is checked as sent, never trimmed or lowercased;
  • ui/src/lib/sso-slug.ts, which the add sheet uses to mark the field, hide the redirect URI preview and block Save. It never rewrites the input, because the slug is registered at the identity provider and the admin has to see exactly what will be saved; it only suggests a corrected value.

sso_slug_outside_the_charset_is_a_400_that_states_the_rule in crates/rolter-control/tests/control_integration.rs asks the store and the endpoint about the same table of slugs and requires the same answer, and sso-slug.test.ts reads the migration and compares the pattern, so a change to one is caught by the others. Widening the rule means a new migration (the applied one is never edited) plus both mirrors.

The dashboard never assembles that URI, or the login URL, from the browser’s origin (#2083). Every provider row the admin API returns carries both, built by redirect_uri() and login_url() in sso.rs (the functions start_login itself uses), so the value on the card is byte for byte the one the flow sends:

{
  "slug": "okta",
  "redirect_uri": "https://rolter.example.com/auth/sso/okta/callback",
  "login_url": "https://rolter.example.com/auth/sso/okta/start"
}

The identity provider wants the redirect URI before the provider exists in rolter, since it issues the client id and secret the add form asks for. So the add sheet previews it from the slug as it is typed, on the base GET /api/v1/public-url returns (public_url.rs, capability public_url, readable by any authenticated caller), with ssoRedirectUri() in ui/src/lib/api.ts mirroring the Rust path. One read serves every keystroke. The endpoint also reports configured: false when ROLTER_PUBLIC_URL is unset, and the screen warns that the default only reaches rolter from a browser on the control plane’s own host. It is deployment-wide rather than SSO-specific so the User Provisioning screen can build its SCIM base URL from the same value (#2079). Both screens read it through usePublicUrl() (ui/src/lib/use-public-url.ts), the one place its query key and options are written, so they share one request.

Testing

Unit and Postgres-gated integration tests drive a stub IdP in-process, which covers rolter’s own logic. Interoperability is a separate question, so the e2e harness runs the same flows against a real Keycloak — genuine discovery document, real JWKS, real login form, real /-prefixed realm groups:

cd integration/e2e && uv run pytest tests/test_sso.py --idp

That suite is nightly and on-demand (.github/workflows/sso-e2e.yml), not part of the per-PR gate.

  • RBAC & auth — roles, scopes and how they are enforced
  • SCIM provisioning — IdP-driven account lifecycle, which pairs with SSO but is independent of it
  • Security — secret handling and the threat model

Two-factor authentication

TOTP (RFC 6238) as a second factor on local-account login (#1078). The operator-facing guide is docs/user-docs/security/two-factor-authentication.mdx; this page is the design and the reasons behind it.

Where the factor is checked

Once, on the login exchange, and nowhere else.

A session is the unit of trust in this control plane (see RBAC & auth): an opaque bearer token backed by a row, revocable immediately, presented on every request. Re-checking a factor per request would mean either keeping the secret somewhere a request path can reach it, or asking the user for six digits every few seconds — and it would buy nothing that revoking the session does not already give.

So POST /api/v1/auth/login does not issue a session when the account has an armed factor. It issues a challenge: a separate short-lived token that authenticates nothing and names only which login is in flight. POST /api/v1/auth/mfa/verify redeems it for a real session.

password ok ──► armed factor?
                 │
                 ├── yes ──► challenge (verify) ──► /auth/mfa/verify ──► session
                 └── no ──► policy requires one, and in force?
                             │
                             ├── no (or still in its grace window) ──► session
                             ├── yes, no ROLTER_KEK ──► 403 mfa_enrolment_required
                             └── yes ──► challenge (enrol) ──► /auth/mfa/enroll
                                                           ──► /auth/mfa/confirm ──► session

The challenge lives in its own table rather than as a flagged sessions row. A challenge grants nothing, and putting it in sessions would mean every future reader of that table had to remember to exclude it — a rule that holds only until someone forgets it.

Enrolment at sign-in

A required_* policy used to refuse a session to an account with no armed factor, and the only enrolment path needed a session, so the member could not fix it and an admin had to relax the policy or run the break-glass reset (#1852). The login exchange now hands such an account an enrolment challenge instead: a row in mfa_challenges with purpose = 'enrol', ten minutes to live, five codes to spend.

What makes it safe to hand out is how little it opens:

  • It is not a session. CurrentUser and Principal look tokens up in sessions and virtual_keys, and it is in neither, so as a bearer it is refused everywhere.
  • It is presented in the body of exactly two routes, POST /api/v1/auth/mfa/enroll (mint a pending secret) and POST /api/v1/auth/mfa/confirm (prove it). Every challenge read names a purpose, so /auth/mfa/verify does not find an enrolment token and the enrolment routes do not find a step-up token. Without that, a code from a secret the token minted but never armed would redeem the step-up.
  • The confirm checks the code, then consumes the token with a delete … returning. Only the request whose delete removed the row goes on to arm the factor, mint the recovery codes and call issue_session, so a double submit cannot mint two batches, the second silently voiding the first.
  • The session comes out of issue_session, the same path every other sign-in takes, so it is audited as auth.login, next to auth.mfa_enabled with at_sign_in: true. The password step writes after_lock on the challenge row (mfa_challenges.after_lock), and the redeeming request reads it back, so a sign-in that followed a lockout says so on its auth.login row even though a later request issued the session. The step-up carries it the same way.

Both writes an enrolment makes decide inside the statement rather than in a read before it, for the same reason the replay check does:

  • MfaRepo::confirm arms the factor only while the row still holds the secret the code was checked against (secret_nonce, fresh per seal). Otherwise a second live enrolment token for the same account, or a second tab, could replace the pending secret between the check and the arm, and the confirm would arm a secret its caller never saw.
  • MfaRepo::begin_enrolment upserts only over a row whose confirmed_at is null. The has_armed_factor check in front of it answers the common case; the condition covers an enrolment racing a confirm, which would otherwise silently disarm the factor that confirm had just armed.

A break-glass reset, a new password set through PUT /users/{id} and a deactivation (API or SCIM) all drop the user’s challenges in flight. An enrolment token is refused while a factor is armed, so without the purge, one minted before a reset would come back to life the moment the reset cleared the factor.

Asking for a secret does not charge the token’s budget; proving one does. The budget is not a guessing defence (whoever holds the token holds the secret) but a bound on how long one token stays useful.

A control plane with no ROLTER_KEK cannot seal a secret, so it keeps the old 403 mfa_enrolment_required. Handing out a challenge that fails one step later would only move the same dead end, and the fix is the operator’s. For the same reason PUT /orgs/{id}/auth-policy refuses a required_* value there with a 409 naming the key: without it nobody can enrol, and every account the policy binds, the admin saving it included, would be refused at its next sign-in.

KEK availability comes from Kek::from_env in production. The integration suite installs one KEK process-wide (#1351) and cannot unset it without racing other tests’ in-flight requests, so ControlState::mfa_without_kek, set only by test_app_without_kek, is how the tests reach these branches.

Both challenge responses carry expires_in next to expires_at, and the dashboard times its prompts from that. Comparing expires_at with the browser clock expired the prompt on arrival for anyone whose laptop clock ran more than the TTL fast, and under required_* that meant never getting in.

The trade is the one every enrol-at-sign-in flow makes: until a member enrols, their password alone gets whoever holds it through enrolment. That is no worse than the policy being off, the grace window below gives members a way to enrol from their own session first, and every enrolment at sign-in is audited. A factor armed by someone else is cleared with break-glass plus a new password; the reset alone leaves the password with whoever used it.

Why SHA-1

RFC 6238 permits SHA-1, SHA-256 and SHA-512, and rolter uses SHA-1. Not because it is the safe default in general, but because it is the only interoperable one here: Google Authenticator ignores the algorithm parameter in the otpauth:// URI and computes SHA-1 regardless, so arming SHA-256 would hand a large share of operators a factor that silently never verifies.

The construction is HMAC rather than a bare digest, so SHA-1’s collision weaknesses do not apply, and the secret is 160 bits of CSPRNG output. The practical attack on a TOTP factor is guessing a six-digit code, which is what the replay and attempt rules below defend.

Replay

A TOTP code is valid for its whole 30-second step, and rolter accepts one step either side to absorb clock skew — so without further defence, one shoulder-surfed code stays usable for up to 90 seconds.

user_totp_factors.last_used_step records the highest step the factor has accepted, and a verification is accepted only for a step strictly greater than it. The comparison is a where clause on the update, not a read followed by a decision in Rust: two logins racing with the same stolen code would both read the same value and both conclude it was fresh, whereas as a conditional update exactly one of them affects a row.

The visible consequence is that the code used to confirm an enrolment is spent, so the first sign-in afterwards needs the next code. That is the rule working. It is called out in the operator docs so it does not read as a fault.

Guessing

Two independent budgets, because they defend different things.

  • The password is guarded by login_throttle (#1079), unchanged: a wrong password still costs the attacker its escalating delay and lockout.
  • The challenge carries its own attempts counter, capped at three, and the attempt is charged in the same statement that reads the challenge — so a caller cannot buy extra guesses by issuing requests in parallel. Once the budget is spent the challenge is dead to the correct code too, which is what stops “hold one challenge open and grind” from working.

The throttle counter is cleared when the password succeeds, before the step-up. Holding the failed-password lock open across the challenge would make a wrong code look like a wrong password and lock the account on the strength of a mistyped digit.

Storage

TableHolds
user_totp_factorsone row per user: the sealed secret, confirmed_at, last_used_step
user_recovery_codeshashed single-use codes; used_at marks a spent one
mfa_challengeslogins in flight; hashed token, purpose (verify/enrol), attempt count, expiry, after_lock
org_auth_policies.mfa_policyenforcement, alongside the password/SSO switches
org_auth_policies.mfa_enforce_afterthe grace window before a required_* policy applies (0076)

The secret is sealed with the deployment KEK — it is a bearer credential, so a database dump alone must not yield one — and is registered in SEALED_COLUMNS so rolter kek verify reports a restore that cannot open it. Recovery codes and challenge tokens are peppered digests, the same construction virtual keys and session tokens use.

There is no model type that serialises the secret. The only path that unseals it is MfaRepo::open_secret, which hands back a non-Serialize struct; MfaRepo::status describes the factor without it, and that is what the API returns.

None of these tables carries a bump_config_version() trigger, and none may grow one: the data plane never reads them, so there is nothing for /internal/snapshot to propagate, and bumping the version on every enrolment would wake the whole gateway fleet for a change it cannot observe. Same reasoning as custom_roles in 0058.

Enforcement

mfa_policy is off / optional / required_superadmin / required_all, and a user’s effective policy is the strictest across their orgs. Taking the first match instead would let a relaxed membership soften a hardened one.

Under a required_* policy the password sign-in never admits an unenrolled account unprotected: it gets the enrolment challenge above, or, on a control plane with no KEK, a distinct mfa_enrolment_required refusal. Telling that user to retype their password would be a lie. Invitation acceptance does not go through this decision yet and issues a session directly (#1935).

mfa_enforce_after lets an org announce the requirement before it applies. SetPolicy reads it as a double option: an explicit null clears it, an absent key keeps the stored value, so a client that predates the field cannot cancel an announced window by re-sending mfa_policy. EffectivePolicy in mfa.rs reduces every org policy that binds the user: strictest wins for the policy, and the earliest start wins for the window, so one hardened org already enforcing is enough and a later window elsewhere cannot postpone it. While every binding requirement is still in its window the password alone signs in, and the session carries mfa_enrol_by so the dashboard can say the date on the way in. required stays true through the window, so removing an armed factor stays refused; the window lets the unenrolled in for a while and never lets the enrolled back out.

Unlike allow_password_login, required_all does not exempt superadmins. The exemption there exists because a broken IdP has no other way back in; here the way back in is rolter mfa reset, which needs no exemption to work.

Break-glass

rolter mfa reset --email ... --reason ... clears the factor and its recovery codes, revokes every live session for the account, drops its challenges in flight, and writes an audit entry carrying the reason. It runs on the host against the database, which is the same privilege level as reading the rows it deletes.

Deliberately not an API endpoint: an endpoint that clears a second factor is a second factor that anyone holding a session can clear.

Sessions go with it because the reason to run it is that the account is in unknown hands; leaving a week-long session alive would leave the factor bypassed regardless.

Not covered

WebAuthn and passkeys. Stronger, and worth their own issue — TOTP is the factor that needs no browser API work and the one every security review asks for.

The dashboard builds on this surface: ui/src/components/SignInEnrolment.tsx is the enrolment step in the sign-in card, loaded lazily so the QR encoder stays out of the chunk every signed-out visitor downloads, and the grace window is the Start requiring it control on the SSO screen’s sign-in policy card.

MCP servers, OAuth grants and sessions

rolter stores the OAuth state behind Model Context Protocol access: which MCP servers an org has registered, which users consented to what, and the token sessions held against those consents. Transport and the authorization-code / on-behalf-of exchange belong to the MCP proxy; this layer owns persistence, listing, revocation and audit, so it is useful to a stdio, SSE, streamable-HTTP or WebSocket implementation alike.

Model

mcp_servers (org-scoped)
   ├── mcp_oauth_grants   one live grant per (server, user)
   │      └── mcp_oauth_sessions   token material, sealed at rest
   ├── mcp_tool_groups    named server/tool policy manifests
   └── mcp_gateway_settings   organization defaults
  • Server — name, slug (unique per org), URL, transport and the OAuth scopes every proxied call requires. Deleting one cascades to its grants and sessions: withdrawing the server withdraws access to it.
  • Grant — a user’s consent against a server, with the scope set they agreed to. A user holds at most one live grant per server (a partial unique index on revoked_at is null); revoked grants are kept so the audit trail survives. Re-consenting updates the scopes in place rather than accumulating rows.
  • Session — the tokens issued under a grant, with expires_at, an optional refresh token and refresh_expires_at.

Token handling

Access and refresh tokens are sealed with AES-256-GCM under the deployment KEK (ROLTER_KEK), the same mechanism as upstream provider credentials — there is deliberately no plaintext column for either. Ciphertext and nonce sit side by side; the KEK never reaches the database.

The store exposes tokens through two credential-only paths. McpOAuthRepo::open_session opens one explicitly selected live session for lifecycle code. The Postgres config store opens only the newest live session per (server, user) whose scopes remain within its live grant and cover the server’s required scopes; those records travel through the token-guarded /internal/snapshot channel already used for decrypted provider credentials. Public API DTOs carry only metadata and a has_refresh_token boolean.

The gateway indexes servers by (org, slug) and sessions by (server, user). A request to /mcp/{server} must authenticate with a database-backed virtual key whose created_by user owns the selected session. The gateway repeats the required-scope check before connecting and replaces the caller’s virtual key with the downstream bearer token. Revocation, expiry and policy writes bump config_version, so snapshot polling removes authorization without a restart.

Who sees what

callergrants / sessions visiblemay revoke
superadmin / admin tokenevery one in the organy
org adminevery one in the organy
org member or vieweronly the ones they ownonly their own
anyone outside the orgnone (403)none (403)

Every listing is joined through mcp_servers.org_id, so a cross-tenant read is not expressible, not merely filtered out.

The curated catalog

LIBRARY in crates/rolter-control/src/mcp_oauth.rs holds the four reviewed server definitions the dashboard offers under MCP Library: GitHub, Sentry, Notion and Linear. Each entry names the server’s URL, transport, tool manifest and the OAuth scopes an authorization request must ask for.

The manifest matters beyond the card it renders on. create_server admits a source: "library" request only when it reproduces the catalog entry element for element, so what is written here is exactly what an installed server ends up storing — and therefore what the tool tally counts, what a tool group can select, and what allow_unlisted_tools is deciding about. While the lists were empty (#1252) an install produced a server that declared nothing at all.

Two properties are worth stating plainly:

  • The lists are a hand-taken snapshot, not discovery. Nothing re-checks them against the live server, so a tool renamed upstream is stale here until someone edits the file. Calling tools/list on the server after install is the real fix and is tracked separately; this is the cheap step that stops the catalog from asserting something false.
  • An empty required_scopes can be the accurate answer. Notion’s remote server grants access to the pages the user picks on its consent screen rather than to named scopes, so it asks for none. Every other entry declaring an empty list would be an unfilled row, and a test enforces that distinction.

Endpoints

  • GET/POST /api/v1/orgs/{org_id}/mcp-servers, PATCH/DELETE /api/v1/mcp-servers/{id} — viewer reads, admin writes. A server URL must be http(s); the transport must be one of stdio, sse, streamable_http, websocket. PATCH updates registry metadata, enabled state, declared tools and required scopes without destroying grants or sessions.
  • GET /api/v1/orgs/{org_id}/mcp/library — curated definitions annotated with whether the slug is installed. Installing one uses the ordinary server create endpoint, so the registry remains the source of truth. See The curated catalog for what an entry declares.
  • GET/POST /api/v1/orgs/{org_id}/mcp/tool-groups, PUT/DELETE /api/v1/mcp/tool-groups/{id} — exact server/tool policy manifests. These definitions are not yet enforced by the proxy, which is why mcp_tool_groups carries the experimental stability marker.
  • GET/PUT /api/v1/orgs/{org_id}/mcp/settings — organization transport and request defaults. The current HTTP proxy still uses deployment-level transport timeouts, which is why mcp_settings carries the experimental stability marker.
  • GET /api/v1/orgs/{org_id}/mcp/grants, DELETE /api/v1/mcp/grants/{id}
  • GET /api/v1/orgs/{org_id}/mcp/sessions, DELETE /api/v1/mcp/sessions/{id}
  • GET/PUT /api/v1/mcp-servers/{id}/oauth-client — the OAuth client rolter presents to the server’s authorization server. Admin-only in both directions: the row names a third party the tenant has chosen to trust. Only client_id is required: authorize_url and token_url are the fallback for a server that publishes no metadata and must be sent together or not at all, issuer pins the authorization server’s issuer identifier for RFC 9207 validation, and discovery is auto (the default) or manual. The client secret is sealed with the deployment KEK on write and never read back; PUT with an empty secret downgrades a confidential client to a public one. The response also reports the RFC 8707 resource every request for this server will carry, and what the last discovery resolved.
  • POST /api/v1/mcp-servers/{id}/oauth/authorize — begin consent. Returns the authorization URL rather than a 302, because the caller is the dashboard over fetch and cannot usefully follow a cross-origin redirect.
  • GET /auth/mcp/callback — where the browser returns. Authenticated by the one-shot login state, not by a session bearer token. A browser is redirected on to the dashboard and any other caller gets JSON; see How the callback answers.
  • POST /api/v1/mcp/sessions/{id}/refresh — renew a session from its stored refresh token.
  • POST /api/v1/mcp/sessions/{id}/exchange — RFC 8693 token exchange for a narrower, downstream session.
  • GET/POST/DELETE /mcp/{server_slug}/{path...} — Streamable HTTP/SSE proxy on the gateway, authorized by virtual-key owner, server and required scopes

Revoking a grant revokes every session under it in the same transaction, so consent and tokens can never disagree. Server creation/deletion and both revocations are written to audit_log.

Which specification revision this targets

The flow implements the MCP authorization specification revision draft, as published at modelcontextprotocol.io/specification/draft/basic/authorization and read on 2026-09-08 (#1347). That date is the thing to check first when the next drift is suspected: the specification moves, and #707 shipped a client that was correct against the revision of its day and had gone three MUSTs stale by the time #1347 was filed.

Three client-side requirements are load-bearing, and all three fail closed — a check that does not pass ends the flow rather than logging and continuing.

RFC 9728 — discovery of the authorization server

An MCP server publishes protected resource metadata naming its authorization servers, and a client is required to use it rather than to be told where to go. crates/rolter-control/src/mcp_oauth_discovery.rs walks the specification’s order:

  1. an unauthenticated GET of the server URL, reading resource_metadata out of the WWW-Authenticate challenge on a 401;
  2. /.well-known/oauth-protected-resource with the server’s path inserted after the suffix (https://h/.well-known/oauth-protected-resource/mcp, not https://h/mcp/.well-known/… — RFC 9728 §3.1 puts it the unusual way round);
  3. the same document at the root.

The document must declare the resource it was fetched for (RFC 9728 §3.3), or a server could hand rolter somebody else’s authorization server. Each listed authorization server is then probed for RFC 8414 or OpenID Connect metadata in the required priority order, and the document’s issuer must be identical to the identifier the URL was built from (RFC 8414 §3.3).

Only the interactive POST .../oauth/authorize probes. What it resolves is cached on the mcp_servers row, and the background refresher and the token exchange read that cache, so nothing off a user’s request reaches out to an upstream. The cache write is skipped when the values have not changed: mcp_servers carries a statement-level bump_config_version() trigger, and an unconditional write would wake every gateway on every consent.

Moving a server’s URL invalidates the cache (#1416). The cached endpoints belong to whatever authorization server the old URL’s metadata named, so PATCH /api/v1/mcp-servers/{id} clears all four oauth_discovered_* columns whenever the update actually changes url. Without that, a refresh landing between the edit and the next interactive authorize would post to the previous server’s token endpoint while naming the new canonical URI in resource — it fails closed, since a compliant authorization server refuses the mismatched audience, but it fails for the wrong reason and re-discovery is the right answer. The clearing is part of the same update statement that writes the URL, for the trigger reason above: a second statement would bump config_version twice for one edit, and an edit that leaves url alone clears nothing.

Hand-configured endpoints remain the fallback. authorize_url and token_url are now optional on PUT .../oauth-client, and the preference order is discovery, then the last cached discovery, then what an operator typed. A row configured before #1347 keeps working untouched: discovery is attempted, finds nothing for a server that publishes nothing, and the configured pair is used. "discovery": "manual" pins a server to the configured pair and skips the probe entirely, which is worth setting for a server known to publish no metadata.

Re-pinning the issuer or the discovery mode invalidates the cache the same way (#1432). The cached triple belongs to whatever authorization server the old issuer and discovery choice named, so PUT .../oauth-client clears all four oauth_discovered_* columns whenever the update actually changes oauth_issuer or oauth_discovery — same trigger, same reasoning as the URL case above, and folded into the same statement for the same bump_config_version() reason. Without it, an operator correcting a rotated oauth_issuer while staying on "discovery": "auto" saw no effect on the refresher or the on-behalf-of exchange, which never probe and were still reading the stale cache; only the next interactive POST .../oauth/authorize happened to re-run discovery and overwrite it. An edit that leaves both fields alone — a secret rotation, say — clears nothing.

RFC 8707 — resource indicators

Every authorization request and every token request — code, refresh and exchange alike — carries resource, the canonical URI of the MCP server the token is for. Without it the token is not audience-bound, and a compliant server, which MUST reject a token that was not issued for it, is entitled to refuse every token rolter mints.

The canonical form is computed in exactly one place, ResourceUri::parse:

  • scheme and host lowercased (uppercase is accepted on the way in);
  • no fragment — RFC 8707 §2 forbids one outright;
  • no trailing slash, the form the specification asks implementations to settle on;
  • the query preserved, since RFC 8707 allows one where it is what scopes the resource;
  • userinfo refused, since a credential has no business in an authorization URL.

The two requests cannot disagree about it, and that is structural rather than a convention: ResourceUri is the only way to name a resource in the crate, its only constructor is that parser, and both authorization_url() and post_token() take one. The value used in the authorization request is recorded on the login state and re-parsed at the callback — parsing is idempotent, so the token request carries the identical string. post_token() then refuses outright to send a form with no resource, which is the guard that catches a future refactor rather than a present bug.

RFC 9207 — issuer validation

Before the browser leaves, the issuer of the validated authorization-server metadata is recorded on mcp_oauth_login_states beside the sealed PKCE verifier, together with whether that metadata advertised authorization_response_iss_parameter_supported. The callback applies the RFC 9207 §2.4 table before the authorization code goes anywhere:

advertisediss presentaction
trueyescompare against the recorded issuer
truenoreject
false or absentyescompare against the recorded issuer
false or absentnoproceed

The comparison is byte equality. No case folding, no default-port elision, no trailing slash, no percent-decoding — reaching for a URL parser here would re-introduce exactly the equivalences a mix-up attack needs. A response that fails the check is refused whole: its error, error_description and error_uri are not acted on or displayed either, which is why the callback resolves the login state and validates the issuer before it reads anything else in the response.

Which line applies is visible over the API: GET /api/v1/mcp-servers/{id}/oauth-client returns discovered_iss_supported beside the rest of the discovery cache (#1569). Without it a client reading only that endpoint can see what was discovered but not how a callback with no iss will be judged, which is the difference between “proceed” and “reject”. Note the naming: the row and the server list use oauth_discovered_*, the client view drops the prefix.

A row with no recorded issuer at all — hand-configured, no metadata, no pinned issuer — lands on the last line of the table and keeps working exactly as it did. But an iss that arrives for such a row is refused, because there is nothing authentic to compare it against and accepting it would be validation in name only. The fix an operator is told about is to pin issuer on the OAuth client, or to let discovery resolve one.

Fetching from the control plane, and SSRF

Discovery means the control plane now fetches URLs it did not fetch before: one derived from the operator-configured server URL, and — the new exposure — the authorization_servers and endpoints of a document a third-party MCP server served. An operator registering an MCP server is already trusted to point the control plane at a token endpoint, so this widens an existing trust rather than creating one, but it widens it to a value the upstream chooses. Three guards bound it, and they are the reason this is acceptable rather than merely small:

  • every URL is https, with http allowed only on loopback;
  • every URL goes through the deployment’s egress policy, which denies link-local (instance metadata) by default and private and loopback ranges when configured to;
  • discovery uses its own HTTP client with redirects disabled, so a host that passed the egress check cannot hand the request to one that would not have, and with a 5-second timeout and a 64 KiB body cap.

What remains uncovered is what the egress policy itself does not cover: a hostname that resolves to a denied address, since the policy classifies IP literals and does not resolve DNS. That is the same connect-time gap every other upstream in rolter has, and it is not made worse here.

The session lifecycle

Consent runs as an ordinary authorization-code flow with PKCE. POST .../oauth/authorize mints a verifier, seals it into a one-shot mcp_oauth_login_states row keyed by state, and returns the authorization URL. The callback consumes that row — a replayed state finds nothing and fails — verifies the code against the sealed verifier, then writes the grant and its first session in one transaction.

A background refresher sweeps every 60 seconds and renews up to 100 sessions per pass, 5 minutes before expiry, so the skew between rolter’s clock and the authorization server’s plus one round trip is always covered. It handles refresh-token rotation by replacing the stored refresh material whenever the response carries a new one. A permanently refused refresh revokes the session rather than retrying: a 4xx carrying invalid_grant or invalid_scope is a final answer about this grant — consent was withdrawn or the token was rotated away — and a retry loop would only hammer the upstream. Transient failures (network errors, 5xx) leave the session alone for the next sweep.

Token exchange (urn:ietf:params:oauth:grant-type:token-exchange) is the server-to-server half. A service acting for a user gets its own session row descending from the same grant, so it can be revoked independently without taking the user’s interactive session with it, and its scopes are intersected against the grant’s — an exchange can never widen consent.

How the callback answers

The callback negotiates on Accept (#2166). A request that accepts text/html is a browser: the authorization server sending the user back. It gets a 303 See Other to the dashboard’s Auth Sessions screen, built on the deployment’s public URL like the redirect URI itself, with the outcome in the query string:

  • consent=completed&session=<id>&server=<id> when the grant and its first session were written;
  • consent=failed&reason=<code> when anything refused, plus &server=<id> once the login state has named the server.

Every other caller, reqwest, curl and fetch included, gets what it always did: ConsentCompleted as JSON, or the usual error body. The response carries Vary: Accept either way, and GET /openapi.json documents both answers.

Only identifiers the caller could already list go in that URL. The authorization code, the state, the tokens and anything the authorization server said stay out: a failure travels as its family, and the dashboard supplies the words. That is also what RFC 9207 asks of a response whose issuer did not check out, which may not be displayed at all. The codes are ConsentFailure in crates/rolter-control/src/mcp_oauth_flow.rs, and they are published values, so one is never renamed:

reasonwhen
access_deniedthe authorization server answered error=access_denied
authorization_failedit answered with any other error
state_invalidno state, no code, or a state that is unknown, expired or already redeemed
issuer_mismatchthe RFC 9207 check failed and the code was discarded
token_exchange_failedthe token endpoint refused the code or could not be reached
not_configuredno ROLTER_KEK, or no resource or OAuth client resolves for the server
internal_erroranything else, a database error most likely

A browser learns only the family, so the handler logs every failure at info with the code, the server and the full error, which is where an operator finds the detail. ui/src/lib/mcp-consent.test.ts reads the Rust enum and fails when the dashboard’s list of codes drifts from it; a code the dashboard does not know still renders a generic sentence.

In the dashboard

The whole flow is drivable from the SPA (#1194), so an operator never has to reach for curl to register a client:

  • MCP Catalog → Configure carries an Authentication section (#1447) that shows only what the selected auth_kind uses: nothing for none, a write-only credential for bearer, a header name plus credential for header, and the OAuth client section below for oauth. It writes through PUT .../auth after the row is saved, and only when the kind, the header name or a typed credential actually moved. That keeps untouched saves out of audit_log, and it keeps a deployment without ROLTER_KEK able to edit a server’s other fields, because the route needs the KEK even for a kind that seals nothing. The header name is checked against the same reserved list as RESERVED_AUTH_HEADERS before sending. Clear stored credential is a confirmed, immediate move to none, because the shape constraint allows no bearer/header row without a credential. Saving a credentialed server as none or oauth asks for the same confirmation. A ROLTER_KEK refusal is shown as a deployment problem next to the control plane’s own message. The dialog also edits the three transport overrides: a blank field is null, and a field left as it was is omitted from the PATCH, so absent and null stay distinct on the wire. The rules live in ui/src/lib/mcp-server-auth.ts with unit tests.
  • The OAuth client section (the oauth branch above) has: a discovery-mode picker (auto | manual), client id, a write-only client secret, the authorize/token URL pair, a pinned issuer and the default scopes (#1415). Only the client id is required under auto, where the pair is an optional fallback; manual requires the pair, and half a pair is refused in either mode. discovery and issuer are sent on every PUT — the handler reads an omitted mode as auto, which used to reset a manual server on every re-save. Under auto a read-only panel shows the row’s oauth_discovered_* columns with oauth_discovered_at, or says nothing has been discovered yet, and warns that saving a new server URL, issuer or mode clears it — the same three changes the store clears the cache on. A moved URL is also flagged beside the URL field whatever the auth kind, since the store drops the cache on a URL change even when the panel is not on screen (#1572). Connect is enabled for a client id alone under auto, and needs the pair under manual. The rules live in ui/src/lib/mcp-oauth-client.ts with unit tests. It reads GET .../oauth-client purely for redirect_uri — the callback is deployment-derived and cannot be worked out from the browser’s origin — and writes through PUT .../oauth-client after the server row itself is saved, which is also how a client is registered on a server in the same action that creates it. The secret is never echoed back: a badge says whether one is stored, and a Clear stored secret toggle sends "" to downgrade the client to a public one. The PUT is skipped entirely when nothing in the section changed, so re-saving a server does not fill audit_log with mcp_oauth_client.update entries nobody made.
  • MCP Catalog → Connect calls POST .../oauth/authorize and opens the returned URL in a new tab. The dashboard never navigates itself there: the consent screen belongs to a third party, and a blocked pop-up is reported rather than left silent, because the request has already succeeded by then.
  • Where consent ends (#2166). The new tab comes back through the callback and lands on Auth Sessions, which reads the outcome from its query string (ui/src/lib/mcp-consent.ts) and states it above the listing, before the listing has loaded: the server by name and the new session highlighted, or the failure in the dashboard’s own words for each reason, with a link back to the catalog. On success it posts one message on the rolter.mcp-consent BroadcastChannel. The catalog tab that started the flow listens and invalidates its mcp-grants and mcp-sessions queries, so the card marks Connected without a reload; a browser without BroadcastChannel falls back to the refetch a query does when its window regains focus. The mark means the signed-in account holds an active grant with an unrevoked, unexpired session under it. It is filtered to that account on purpose, since an admin’s listings carry every member’s rows.
  • Auth Sessions → renew calls POST /api/v1/mcp/sessions/{id}/refresh for one row, beside the background sweeper. Only a session that stored a refresh token offers it. A refusal is worth reading rather than retrying, since the control plane has already revoked the session by the time the error arrives.

Static credentials, beside OAuth

OAuth is one of four things mcp_servers.auth_kind can name (#952). The others are none, bearer and header — one deployment-wide credential rather than a token minted per user — and they exist because most hosted MCP servers today issue a long-lived token or an API key rather than running an authorization server.

The schema, not the handler, is what keeps the four coherent. mcp_servers_auth_kind_shape requires bearer and header to hold a sealed credential and forbids one to none and oauth, and requires a header name for header alone. So a row cannot claim to be unauthenticated while holding a secret, and has_credential in the read API cannot contradict what the row says it does. The handler checks the same rules first only so an operator gets a 400 naming the problem instead of a 500 from the constraint.

mcp_servers_auth_header_name_shape restricts the header to an RFC 9110 field name and refuses ten reserved ones. Authorization is the one that matters: without that clause, header mode could present an API key exactly where the bearer path puts a token.

The credential is sealed with the deployment KEK through the same Kek helper as the OAuth client secret, registered in SEALED_COLUMNS, and projected as has_credential — the plaintext has no field on any Serialize type. Writes follow the same three-shape convention the client secret uses: absent leaves the stored value, "" clears it, anything else replaces it. That is what lets an operator move a server from bearer to header without re-typing a secret the API gives them no way to read.

Moving to a kind that carries no credential clears it rather than orphaning it, whatever the caller sent. A sealed secret nothing can use is one rolter kek verify would audit forever, and it is a credential still sitting in a backup for no reason.

A stored credential reaches the data plane through /internal/snapshot (#952). load_mcp_servers unseals it with the KEK and puts the plaintext on McpServerConfig, the same route an OAuth access token already takes — that endpoint is the credential-bearing control-to-data-plane channel, so this adds a secret to it rather than opening a new path for one. McpServerConfig’s Debug redacts the field so formatting a snapshot cannot spill it into a log or a crash dump, and the header form marks the value sensitive before it reaches the wire.

A row whose credential cannot be unsealed — ROLTER_KEK unset or rotated — keeps its other fields and loses only the secret, and the proxy then refuses that server with mcp_credential_unavailable rather than calling it unauthenticated. Dropping the server from the snapshot instead would make a misconfigured KEK look like a server that was never registered.

Per-server transport overrides

connect_timeout_ms, request_timeout_ms and max_retries are nullable on mcp_servers, and the proxy reads them (#952). Null means inherit rather than a copy taken at creation, so the value a server never overrode still moves when the value it inherits does.

What it inherits is the deployment transport timeout, not the org’s mcp_gateway_settings: those org defaults are still not read by the data plane, which is what the mcp_settings stability marker records and what #1404 closes. Until then a per-server override is the only way to give one server its own budget.

reqwest bakes the connect timeout into the client rather than the request, so a per-server connect_timeout_ms means a client per distinct value; they are built once and cached, and there are only ever a handful. max_retries repeats only a send that failed to connect: an MCP call is frequently a tool invocation and is not idempotent, so a request the server may have seen — including one that timed out — is reported rather than replayed.

On the wire the PATCH distinguishes absent from null — leave the override versus drop it — which an Option alone cannot express. serde collapses both to None for an Option<Option<T>>, so the fields deserialize through explicit_null, which returns Some(None) for a present null. Without it “stop overriding” silently means “leave it”.

Not supported, and why

  • stdio — removed as a transport in #783, since a hosted control plane cannot dial a local subprocess. The MCP specification agrees from the other side: stdio implementations “SHOULD NOT” use its authorization flow and should “retrieve credentials from the environment”, so there is nothing here to store for one.
  • mTLS — a client certificate is an identity, not a string, and there is no certificate store to put one in.
  • Dynamic Client Registration (RFC 7591) — the specification now marks it deprecated, retained only for authorization servers that do not support Client ID Metadata Documents. The decision recorded in #1347 is that rolter does not implement it and will not: if client registration is ever automated here it will be Client ID Metadata Documents (draft-ietf-oauth-client-id-metadata-document-00), which the specification now says clients SHOULD support, and DCR would be a second registration path to keep secure for a mechanism its own specification is walking away from. A client_id therefore stays operator-supplied; it is the one part of the client the flow will not discover.

header mode is deliberately outside the specification, which defines only Authorization: Bearer. It is an accommodation for real servers, and both the operator docs and this page say so rather than implying conformance.

SCIM 2.0 provisioning

An identity provider can create, update, deactivate and reconcile rolter accounts and groups over SCIM 2.0 instead of an operator doing it by hand. Users answer who exists; groups, through an operator-written mapping, answer what they may do.

Tokens carry the tenant

There is no org id anywhere in the SCIM path. An operator mints an org-scoped provisioning token, and that token is what resolves the tenant:

POST   /api/v1/orgs/{org_id}/scim-tokens   { "name": "okta" }   # org admin
GET    /api/v1/orgs/{org_id}/scim-tokens
DELETE /api/v1/scim-tokens/{id}

The plaintext token (rolter_scim_…) is returned once, at creation. Only its peppered SHA-256 digest is stored — the same treatment as session tokens — so a database dump alone cannot be replayed, and a lost token is rotated rather than looked up. Revoking takes effect on the very next request; there is no cache to invalidate.

Because the token resolves to exactly one org, an IdP cannot address another tenant’s users: a foreign resource id is a 404, not a redacted row.

The base URL an IdP is pointed at

The resource endpoints below are mounted at the root of the control plane (scim.rs, scim_groups.rs), so the base URL a connector needs is the control plane’s public URL plus /scim/v2. The control plane builds every address it gives an outside caller from ROLTER_PUBLIC_URL, never from the request, and the dashboard can be open under another name than the one the IdP must call, so the User Provisioning screen does not derive the URL from window.location (#2079).

It reads GET /api/v1/public-url (public_url.rs, capability public_url, readable by any signed-in caller) and appends the path with scimBaseUrl() in ui/src/lib/api.ts, which mirrors the mount; ui/src/lib/scim-urls.test.ts pins the pair. The read goes through usePublicUrl(), which the Single Sign-On screen calls too, so both screens share one request and agree on whether the variable is set.

The address is shown twice with a copy button each: above the token list, and in the reveal step beside the token, where an operator holds a secret that is never shown again. With configured: false the control plane’s default http://localhost:4001 stands in for the variable. The screen still shows it and says, under it, that an IdP can reach it only from the control plane’s own host. The address appears only beside a token list that was read, since a caller refused the list or a control plane with no store has no connector to point anywhere. A failed read of the public URL shows a LoadError with a retry instead of a guessed host.

Users

GET    /scim/v2/Users?filter=userName eq "ada@example.com"
POST   /scim/v2/Users
GET    /scim/v2/Users/{id}
PUT    /scim/v2/Users/{id}
PATCH  /scim/v2/Users/{id}
DELETE /scim/v2/Users/{id}
  • Filters. Only userName eq "value" is supported — the one shape reconciliation needs. Any other filter is a 400 with scimType: invalidFilter rather than being ignored: silently returning the whole directory reads to an IdP as “no such user”, and it then re-creates the account.
  • Idempotence. IdPs retry. Creating an existing userName returns 409 with scimType: uniqueness instead of a second account. If the email already belongs to a local account (invited by an admin, or provisioned elsewhere), that account is adopted rather than failing on the unique email, so provisioning converges.
  • PATCH. The active toggle is implemented, in both the path: "active" and bare {"active": false} forms, and with the string "true"/"false" some IdPs send. Any other operation is a 400 — an IdP that gets a success for an operation nothing applied would believe the change landed.
  • Deactivation logs the user out. Setting active: false stamps deactivated_at and deletes the account’s live sessions, because an IdP disabling a leaver expects them out now, not merely unable to log in again.
  • DELETE deprovisions. The account is deactivated, its sessions dropped, and the org’s SCIM identity mapping removed. The users row itself stays: its memberships and audit trail must outlive any one IdP, and a later POST adopts it again.

No password path

A password attribute in a SCIM body is ignored, not honoured. Provisioned accounts are SSO-shaped: password_hash stays null, and there is no code path through which SCIM can set or read a local credential.

Groups

GET    /scim/v2/Groups?filter=displayName eq "platform"
POST   /scim/v2/Groups
GET    /scim/v2/Groups/{id}
PUT    /scim/v2/Groups/{id}
PATCH  /scim/v2/Groups/{id}
DELETE /scim/v2/Groups/{id}

Same token, same tenancy rule, same ScimError envelope as Users.

  • Filters. Only displayName eq "value" is supported, for the same reason userName eq is the only Users filter: answering an unsupported filter with the whole directory reads to an IdP as “no such group”, and it then creates a duplicate.
  • Members must already be provisioned here. A members entry has to be a user with a SCIM identity in the token’s org. Anything else — an unparsable id, a local account the IdP never created, another tenant’s user — is a 400 with scimType: invalidValue. A group can therefore never be a side door into an account the Users surface did not make.
  • PATCH. The operations IdPs actually send are implemented: add/replace on members (array of {"value": id}, array of bare ids, or a single entry), remove on members both with a value list and in the filtered members[value eq "…"] path form Okta uses, remove on the whole attribute to empty the group, replace on displayName/externalId, and the pathless {"op": "replace", "value": {…}} shape. Anything else is a 400.
  • Idempotence. A replayed create is a 409 with scimType: uniqueness. A replayed add of an existing member, or a PUT re-sending the same member list, changes nothing.
  • DELETE. The group row goes and its members are reconciled in the same request, so the roles it granted disappear immediately rather than at the next sync. A second DELETE is a 404.

Group→team mapping

A mapping is written by an operator, not by the IdP — the IdP may not decide what its groups are worth:

POST   /api/v1/orgs/{org_id}/scim-group-mappings   # org admin
       { "group_name": "platform", "role": "member", "team_id": "…" }
GET    /api/v1/orgs/{org_id}/scim-group-mappings
DELETE /api/v1/scim-group-mappings/{id}

This is deliberately the SSO group-mapping model, down to the vocabulary: a group_name grants one of admin/member/viewer at the most specific non-null scope id (team_id, project_id, or the org itself when neither is named), and a mapping may only grant inside its own org — a team or project belonging to another tenant is a 400. Two divergent group-mapping models in one control plane would be two things for an operator to learn and two places for a privilege bug to hide.

Mappings key on the group’s displayName, which is what an operator sees in the IdP UI. Renaming a group in the IdP therefore detaches it from a mapping written against the old name; rewrite the mapping, or keep the SCIM display name stable.

A mapping may be written before the IdP has ever mentioned the group. Creating or deleting one reconciles that group’s current members straight away, so a mapping change does not wait for the next sync.

Reconciliation converges

Every group write recomputes the affected users’ memberships from the database, never from the request body. The wanted set is “every mapping of every group this user is currently in”; source = 'scim' rows that are not in it are deleted, missing ones are created. Three consequences:

  • A scheduled sync re-sending the same group state is a no-op.
  • Dropping a user from a group revokes exactly what that group granted, in the same request.
  • A grant an operator made by hand carries source = 'manual' and is never touched — the same rule SSO logins follow, so the enrolment paths can be used side by side. An equivalent manual grant also suppresses creating a duplicate scim row.

DELETE /scim/v2/Users/{id} drops the account from every group in the org before removing the identity, so a deprovisioned account cannot keep a group-granted role.

Roles

A provisioned account is granted a viewer membership at the org it was provisioned into, merely for existing — least privilege on purpose. Anything beyond a read comes from a group mapping an operator wrote.

Audit

scim.user.create, scim.user.update, scim.user.deprovision, scim.group.create, scim.group.update and scim.group.delete are written to audit_log, scoped to the org, with the provisioning token’s id in the detail — the actor is a token, not a human. Operator actions carry the acting operator instead: scim_token.create / scim_token.revoke, and scim_group_mapping.create / scim_group_mapping.delete.

Security

Secret handling

  • Upstream provider keys are never stored in plaintext in the database. They are envelope-encrypted with AES-256-GCM: a per-record data key/nonce, wrapped by a master key (KEK) supplied via ROLTER_KEK (env/file). Pluggable backends (HashiCorp Vault, cloud KMS) are a roadmap item.
  • In the bootstrap file, prefer api_key_env over inline api_key so secrets stay in the environment, not on disk.
  • Virtual keys are stored as hashes with a short display prefix; the raw key is shown once at creation.
  • Secrets are never logged. The gateway redacts auth headers from traces.

Transport

  • Upstream calls use rustls (no OpenSSL). HTTP/2 keep-alive with connection pooling.
  • Optional per-provider egress proxy (egress_proxy, HTTP/HTTPS/SOCKS5) for networks where providers aren’t directly reachable.
  • Optional global or per-provider custom CA bundles add private PKI roots to outbound upstream clients while retaining public roots, certificate-chain validation, and hostname verification.
  • Terminate TLS at the gateway or a fronting proxy/ingress in production.

Cross-origin policy (CORS)

security_settings.allowed_origins and allowed_headers are enforced by a middleware on the control plane (crates/rolter-control/src/cors.rs). The policy is read from an ArcSwap per request, so an edit through PUT /api/v1/security-settings applies immediately rather than at the next restart.

Only the control plane has one, because it is the only origin a browser talks to: it serves the dashboard, and the Playground reaches the data plane through the /gw/* reverse proxy rather than calling the gateway directly. A gateway CORS layer would govern requests browsers do not make, and would need SecuritySettings in the snapshot to do it.

Behaviour:

  • No origins configured is the default, and the same-origin deployment everyone runs. The middleware adds no headers at all and the request path is unchanged — CORS is not needed when the control plane serves both the dashboard and the API.
  • Matching is exact against the configured list, modulo case and a trailing slash. There is no wildcard and no suffix match, so evil-dash.example.com and dash.example.com.evil.test do not inherit an entry for dash.example.com. validate_origin already rejects * at write time.
  • A denied origin gets a normal response with no CORS headers; the browser enforces the block. Vary: Origin is set on every answer so a shared cache cannot serve one origin’s response to another.
  • Preflights are answered by the middleware with 204 and never reach the router, since they carry no credentials.
  • authorization, content-type, traceparent, tracestate and x-request-id are always allowed, unioned with whatever is configured. The trace headers are what let the dashboard’s spans join the gateway’s on a split-origin deployment; leaving them to configuration means browser trace propagation silently does not work until someone remembers them.
  • x-request-id is exposed via Access-Control-Expose-Headers, so a browser can read the correlation id the API already echoes.

Gateway ingress policy (#1162)

The rest of security_settings — the part that is not CORS — reaches the gateway through /internal/snapshot as GatewayConfig::security (rolter_core::SecurityPolicyConfig), loaded by PostgresConfigStore::load_security_policy. The table’s own trigger bumps config_version, so an edit propagates on the next poll like any other config change, with no restart.

Only the policy columns are selected. The dashboard credential ciphertext and nonce are not named by the query at all — the migration promised snapshots would never carry them, and a query that cannot see a column cannot leak it.

SettingEffect on the gateway
virtual_key_requiredan unauthenticated request is refused even where the gateway holds no keys
required_headersa request missing any name/value pair is refused at ingress, before routing and before auth
auth_bypass_routesthe named paths answer without a key

Load-bearing properties, each with a test that fails if it stops holding:

  • Every rule can only close, never open — except auth_bypass_routes, which an operator writes out path by path. The default for the whole struct is “no extra rules”, so a store that cannot be read leaves the deployment where it was rather than opening one that was closing.
  • server.require_auth in the config file still wins, in both directions. A file is a deliberate local override by whoever runs the process, who may not be the person holding the dashboard.
  • Bypass matching is exact. /v1/models does not open /v1/models/gpt-4o, and validate_bypass_route already refuses wildcards and non-/v1 paths at write time. The MCP surface passes a literal /mcp into authenticate rather than its real path, so no bypass entry can ever name it.
  • /healthz and /readyz are exempt from required_headers. A probe is the deployment’s own traffic; filtering it takes the gateway down rather than protecting it.
  • A refusal names the header and never its value. An operator may well have put a shared secret in a required header, and echoing it would hand it to the one caller who did not know it.

allow_direct_provider_keys is gone from the API and the dashboard. The gateway has no direct-provider-key passthrough, so the column controlled nothing; the store now pins it to its default. A toggle that reads like a security control and does nothing is worse than an absent one, because it converts into a false belief during exactly the review where it matters.

The Security screen (#2103, #2114)

The dashboard’s Security screen (ui/src/pages/Security.tsx) saves every field in one PUT /api/v1/security-settings, and the control plane refuses the whole save when one entry breaks a rule in validate_settings. Three pieces of the screen exist to keep that refusal from being the first the operator hears of it.

  • The list rules are mirrored in ui/src/lib/security-lists.ts. Every list is one entry per line, and an entry that does not parse is kept in the field and named with its line, never dropped. The functions accept what validate_settings accepts and refuse what it refuses, no stricter and no looser. The one extra rule is a required header named twice, since the store keeps the pairs as a map and one of the two values would vanish. security-lists.test.ts reads security.rs and fails when the forbidden-character sets or the /v1/ prefix change there, so a rule widened on one side shows up as a red test rather than as a toast.
  • ui/src/lib/security-loosening.ts decides which saves ask first. Only three edits loosen: virtual_key_required going on to off, dashboard_auth_enabled going on to off, and a path added to auth_bypass_routes. It compares the draft with what the store held at the last load or save, and a save that only tightens goes out without a dialog (see destructive actions).
  • ui/src/lib/gateway-pickup.ts reads the fleet after a save. The write bumps config_version (the table’s trigger), each gateway reports the version it runs on its snapshot poll, and GET /api/v1/cluster/nodes compares the two as converged. The screen asks for the inventory under a key of its own per save, so an answer read before the save is never taken for one after it, and counts the live gateways that converged. With no live gateway on record, or a failed read, it says pickup cannot be confirmed and when a gateway applies it (next poll, ROLTER_SNAPSHOT_POLL_SECS, 5 s by default; at once with Redis pub/sub).

The virtual-key switch is narrower than its label in the gateway: authenticate in crates/rolter-gateway/src/handlers.rs reads virtual_key_required || managed_auth for a gateway with an empty key set, and every gateway that receives the setting through a snapshot is managed, so the switch never changes what one of them does (#2357). The confirmation words the consequence as the documented one (the gateway “decides by how it was started”) and claims no more. dashboard_auth_enabled and the dashboard credential are stored and returned, and no code path in the control plane reads them yet (#2356), so the dashboard-protection row says what the setting asks for and nothing about an effect.

Egress policy (SSRF)

A provider’s api_base decides where the gateway sends traffic, so an admin surface that accepts an arbitrary one turns the proxy into an SSRF primitive aimed at whatever the gateway’s network position can reach.

rolter is built to run self-hosted and air-gapped, so upstreams legitimately live on loopback, RFC1918 and container networks — blanket-denying private destinations would break the core use case. What no legitimate LLM upstream needs is the link-local range, which is where cloud instance metadata lives (169.254.169.254, fe80::/10). That is denied by default; everything else is opt-in:

[egress]
block_link_local = true   # default — cloud instance metadata
block_loopback   = false  # sidecar / single-host deployments
block_private    = false  # on-prem clusters
allow_hosts      = []     # exact-host escape hatch

Enforced in two places: the control plane rejects a denied api_base at write time (400, so it never reaches the database), and snapshot validation re-checks it, so a bootstrap toml can’t smuggle one in either.

Config-time validation classifies IP literals only. Resolving hostnames there would make validation depend on live DNS — reintroducing the “one bad row freezes every gateway” failure mode described in config-and-hot-reload.md — and would be bypassable by DNS rebinding regardless.

A literal is read the way the HTTP client reads it. EgressPolicy::check_url parses with the WHATWG url crate that reqwest uses, because that parser normalizes the host before anything connects: http://2852039166/, http://0xa9fea9fe/, http://169.254.43518/, http://0251.0376.0251.0376/ and http://169.254.169.254./ all dial 169.254.169.254, and a backslash ends the authority, so the host of http://169.254.169.254\@example.com/ is the address. A hand-rolled host split saw none of those as that address, and hyper never calls a resolver for an IP literal, so the connect-time check below did not see them either. deny_reason also classifies an IPv4-mapped IPv6 address (::ffff:169.254.169.254) as the IPv4 address it carries and treats the unspecified address (0.0.0.0, ::) as loopback, since connecting to it reaches the local host. A socks proxy URL keeps its host opaque to the parser, so that host is parsed again as an http host would be. allow_hosts entries are parsed the same way and match the address, so an entry for 169.254.169.254 also covers 0xa9fea9fe.

So the same policy is enforced a third time, at connect time, by a custom resolver on every upstream client: whatever DNS actually returns is classified immediately before the connection is made. That covers the two cases config validation cannot see — a hostname like metadata.internal, and a name that resolved to a public address when it was configured but resolves to 169.254.169.254 at request time. A denied destination surfaces as a policy error naming the host, not an opaque connect failure.

A name that resolves to several addresses keeps the permitted ones: refusing the whole name would take down a legitimate multi-homed upstream. Only a name left with nothing is refused — which is exactly what rebinding to a denied address produces. The resolver reads the policy from a live handle, so a hot reload re-tunes enforcement without discarding pooled connections.

The control plane’s own requests to operator-supplied URLs (the connector test probe, alert channel delivery, MCP OAuth discovery) check the same policy on the stored URL when it is saved and again before each request. Alert delivery and MCP OAuth discovery also refuse redirects, so a 3xx cannot hand the request to a host the check never saw. None of them has a connect-time resolver yet, and the connector probe still follows redirects; both are tracked in #1949.

Control-plane input validation

Every control-plane mutation body is decoded through a SafeJson extractor rather than axum’s Json. Before the body is deserialized into its typed struct, every string in it — nested objects, arrays and object keys included — is screened for control characters, and the request is rejected with a 400 naming the offending field.

The concrete failure this prevents: Postgres text columns cannot store a NUL byte, so a field carrying one failed deep inside the store and surfaced as an unhandled 500 instead of input validation, violating the “bounded error, no unwrap/expect on a request path” invariant. Screening the whole C0/C1 range (and U+007F) also closes the log-injection vector a raw escape or newline would otherwise open in operator-facing logs.

Tab, newline and carriage return stay allowed — multi-line values are legitimate, a PEM CA bundle being the obvious one. Malformed JSON now also comes back in the same OpenAI-style error envelope as every other failure instead of axum’s default rejection body.

Open mode (no admin token)

With no ROLTER_ADMIN_TOKEN set, Principal short-circuits to Superadmin for every request: the management API and /internal/snapshot have no authentication step to fail. This is the zero-credential local-dev shape, and crates/rolter-control/src/open_mode.rs is what keeps it from being anything else. Before either listener is opened, it evaluates “is a token set” against every address about to be bound:

admin tokenbindoutcome
setanyClosed — RBAC enforced
unsetall listeners loopbackOpenLoopback — allowed, warned
unsetany non-loopback listenerrefuses to start
unsetnon-loopback + --allow-open-modeOpenAcknowledged — allowed, warned loudly

--internal-addr counts as a listener here: an exposed credential channel is no better than an exposed API, so either one alone is enough to refuse.

The decision also rides into the dashboard through window.__ROLTER_CONFIG__ (openMode: true), which renders a persistent banner. Without it the dashboard looks identical whether the control plane is gated or wide open, which is the property that made this dangerous rather than merely permissive (#970).

This is why ROLTER_CONTROL_HOST defaults to 127.0.0.1 rather than 0.0.0.0: containers and clusters set it explicitly, and by then they have a reason to have set a token too.

The published image is one of those containers. Its default command runs rolter easy-up --host 0.0.0.0, since a published port cannot reach a container’s loopback (#1891), and leaves the refusal above in charge: a bare docker run of the image with no token exits rather than serving. easy-up applies the same rule before it writes, seeds or binds anything, to the host its control plane binds (--control-host/ROLTER_CONTROL_HOST, else --host/ROLTER_HOST) and, in database mode, to the gateway’s host as well, because the gateway then serves the management API at /admin/* by proxying to the co-hosted control plane. The bind is a flag on that command, not an image-wide variable, so a rolter-gateway or rolter-control run from the image keeps its own default.

Who reads the request log (#1820)

/api/v1/analytics/* (the request log, usage, spend and attribution rollups) and /api/v1/health/* (provider uptime, MTTR, the failure timeline) are merged onto the part of the router that also serves the probes and /api/v1/config, and until #1820 that made them open: neither resolved a principal, so a caller with no credentials read every tenant’s request logs — captured prompt and completion bodies included — on a deployment whose CRUD API was enforcing RBAC.

Every handler in both modules now takes an AnalyticsAccess (crates/rolter-control/src/analytics_access.rs). It authenticates the way the CRUD API does and turns what the caller holds into a filter each query binds as ClickHouse parameters — never spliced SQL:

callerrequest-log rowscaptured bodiesprovider health
admin token, superadmin session, open modeall, including rows logged with no orgallall
a user with memberships or custom rolesrows whose org, team or project any of their roles reacheswhere their role at the row’s scope meets the request_payload floor (member)provider names used in orgs where they hold an org-level role
the same user, on a project set to payload_min_role = viewerunchangedalso where they hold only viewer thereunchanged
no credentials, or a bearer that is neither token nor session401401401

Two details carry the design:

  • Rows are filtered and bodies are masked in the database. Viewer is the lowest role, so a row is visible when any role reaches it. A body needs the role resolved most-specific-first exactly as rbac::resolve_role does — an org admin who is a viewer on one project reads that project’s rows without its bodies — and raised by custom roles as rbac::custom_base_role does. A withheld body comes back empty with payload_withheld = 1, so the dashboard says “hidden for your role” instead of “payload capture is off”.
  • The floors live in the capability matrix. analytics:read (viewer), request_payload:read (member) and provider_health:read (viewer) are rows in CAPABILITIES, and the extractor reads its thresholds from them through cap!, so GET /api/v1/rbac/matrix publishes the rule the filter applies. The per-project exception is project_settings:update (admin), stored in projects.payload_min_role; GET /api/v1/rbac/effective folds it in when the scope it is asked about names a project and the caller holds a role on that project’s own chain, read from the project row rather than from the query string, so naming another org’s project beside one’s own org reads nothing back.

A captured body is joined to its log row on (request_id, ts), never on the id alone. The gateway keeps whatever x-request-id the caller sent, so two tenants’ requests can share an id; an id-only join let a caller log a bodiless request under an id seen on another project’s rows and read that project’s prompt through their own row, which the mask passed because it judges the row, not the body. PayloadLog copies its log row’s ts through the same serializer, so the pair names one request.

The pair is not a perfect key. Two requests that share an x-request-id and were logged in the same millisecond (ts is DateTime64(3)) still get one body between them, which takes a client sending predictable or constant ids. Keying the join on something the caller cannot choose is #1937. The pair also drops bodies written by gateways from v0.1.0 or earlier, which let ClickHouse stamp the log row and the payload row separately: after the control plane is upgraded those bodies stop showing until payload retention removes them. The user docs’ upgrade page says to upgrade gateways first for that reason.

Two limits are known and tracked:

  • Provider health matches by name. provider_health_events carries the provider’s display name and no org, and names are unique per org only. A name that another org used and then deleted brings its history (uptime, latency, error kinds, target_id) to whichever org creates it next, until the 90-day TTL drops it. Provider names are unique across the deployment, so two orgs never hold one at the same time. Recording the org in the rows is #1908.
  • No database, no dashboard. A control plane with ROLTER_ADMIN_TOKEN and no store has no sessions, so the admin token is the only credential these routes accept, and the dashboard’s e-mail-only sign-in cannot present it. The routes stay closed on purpose; letting the dashboard take the token is #1909.

What keeps this from regressing is every_route_the_spec_does_not_mark_public_refuses_an_anonymous_caller in crates/rolter-control/tests/control_integration.rs: it walks the served /openapi.json with no credentials and a forged bearer and requires a 401 from every GET the document does not mark public. The routes that are open by design (/api/v1/ping, /roles, /provider-kinds, /currency, /config, /config/problems) are marked .public() in openapi.rs for that reason, and crates/rolter-control/tests/analytics_scoping.rs pins the row and body rules against a real ClickHouse.

One org never reaches another (#1844, #1845)

A provider’s credential belongs to the org that stored it, and until #1844 the gateway did not know that: the snapshot carried routes, providers and provider groups with no owner, so a key from any org could call another org’s routes, or address its providers directly as provider-slug/model or through a group as group-slug/model, spending that org’s credential. Two orgs that picked the same provider name also froze config propagation for everyone, because the snapshot refuses duplicate names.

Every row loaded from the store now carries its org (and a route its project) as tenancy, and the gateway resolves a model only among the rows the key’s org may use, on all three address forms and in GET /v1/models. Another org’s row is a miss: it shadows nothing of the caller’s own, and the caller gets the 404 an unknown model gets. On the write path a route target or group member must name a provider in the same org. Names the gateway indexes deployment-wide (route and provider names, and the one slug namespace providers and groups share) are unique across orgs, and so is a route name that would sit on another org’s slug/model address. A collision is refused with a 409 that does not say which org holds the name. The route-name check runs one way: a slug created after another org’s slug/… route is accepted, and keys that carry no org then reach that route rather than the new address (the ADR-0017 addendum explains why). An admin can also narrow a route to its own project (project_only), which narrows that route but not the slug/model address of the provider behind it (#1919). The contract, the table of which keys admit which rows, and the write-time guards are in RBAC & authentication; per-org namespaces, which would let two orgs reuse a name, are #1857.

Control↔data-plane trust boundary

GET /internal/snapshot returns provider api_keys decrypted. That is by necessity — the data plane needs the upstream credential to authenticate to the provider — but it makes the snapshot channel a different trust boundary from the operator-facing management API, and it should be configured as one:

ROLTER_INTERNAL_TOKEN=...            # gates /internal/*, distinct from ROLTER_ADMIN_TOKEN
ROLTER_INTERNAL_ADDR=127.0.0.1:4002  # serves /internal/* on its own socket

With ROLTER_INTERNAL_TOKEN set, the operator admin token no longer opens the snapshot — only the gateway’s own credential does. With ROLTER_INTERNAL_ADDR set, /internal/* is not mounted on the public API router at all, so it is absent from the port the dashboard and management API are served from rather than merely gated on it. Bind it to loopback or a private interface. The gateway sends ROLTER_INTERNAL_TOKEN on snapshot polls, falling back to ROLTER_ADMIN_TOKEN.

Both are optional. Unset, the historical behavior holds — /internal/* shares the public listener and accepts the admin token — and the control plane logs a warning at startup saying so. The tenant-facing CRUD surface never returns a provider key in any configuration.

Two things this deliberately does not do. There is no mTLS between the planes: a shared secret over a private interface is comparable strength when the network path is already trusted, and mTLS adds certificate lifecycle to an air-gapped deployment. And the snapshot still carries plaintext rather than sealed ciphertext: envelope-passing would require the gateway to hold the KEK, moving the master secret onto every data-plane node, which is a worse trade for most deployments. Revisit both if the planes ever cross an untrusted network.

Wire transparency

  • Outbound requests to upstream providers carry no rolter-identifying marks: no User-Agent, no added X-*/Via headers, no metadata injected into the JSON body, no marks in SSE framing. The only headers sent are functionally required ones — content-type, the provider’s auth header, and anthropic-version for Anthropic.
  • Responses back to clients likewise gain no rolter-added headers.
  • This is a tested guarantee: golden wire tests in rolter-proxy capture the raw outbound request head and fail on any unexpected header (see openai_wire_carries_no_rolter_signature).

External PII sanitization (#848)

The gateway can hand request content — and optionally response content — to a self-hosted de-identification service before it reaches a provider. Configured under [pii_sanitizer]; see docs/user-docs/security/pii-sanitizer.mdx for the operator-facing contract.

The design property worth stating precisely: the placeholder→plaintext mapping never enters the gateway process. The sanitizer substitutes deterministic placeholders and returns an opaque restoration token; rolter holds the token and nothing else. There is therefore no log line, metric label, span attribute or cached body from which the original values can be recovered, and no in-memory table for a crash dump to expose.

Consequences that fall out of that choice:

  • RestorationTicket is not printable. Its Display renders <restoration token redacted>, and the token is reachable only through token_for(&scope), which returns None unless the caller’s org/team/project/route match the scope the ticket was minted under. A token from one project cannot restore content in another.
  • Restoration is opt-out, not opt-in-by-default. RestorationPolicy::Never is the default; CallerAuthorized honours x-rolter-pii-restore only because the policy allows it, never because the caller asked.
  • The response leg never requests reversibility. Restoring provider-generated content would return the very data that leg exists to remove.
  • A streamed response with an active response leg is refused (pii_streaming_unsupported, HTTP 400), mirroring guardrails.streaming_post_call. Correct streaming restoration would need either a network round trip per chunk — destroying TTFT — or the mapping held in-process, destroying the property above. streaming = "passthrough" waives the response leg instead.
  • A malformed sanitizer reply is a failure, unlike the guardrail webhook’s decision parsing which defaults to allow. Defaulting would forward content the gateway believes is sanitized and is not.
  • Restore runs after the cache store, so what is cached is what the upstream said. A later hit on the same entry is re-evaluated against its own request’s policy and ticket rather than replaying someone else’s restored plaintext.
  • fail_open is the default and is the risk to monitor. It forwards unsanitized content when the service is down. rolter_pii_sanitizer_errors_total rising under fail_open means personal data is reaching providers; deployments where the sanitizer is a compliance control should set fail_closed.

A response-leg failure never fails the request even under fail_closed: the upstream call already happened and was already billed, so refusing to deliver would spend the caller’s money and return nothing. The counter records it.

Streamed responses and post-response controls (#1776)

Output guardrails, post_response plugins and the PII sanitizer’s response leg all judge a whole response body, and a stream reaches the caller before any whole body exists. A setting that the client flips with "stream": true must not switch off a control the operator configured, so each of the three refuses the stream up front with a 400 and param: "stream", before the cache lookup or the upstream call. Each has an explicit way to waive the refusal.

ControlRefused withCounterWaived by
output guardrailsguardrail_streaming_unsupportedrolter_guardrail_stream_rejections_totalguardrails.streaming_post_call = "passthrough"
post_response pluginplugin_streaming_unsupportedrolter_plugin_stream_rejections_totalthe plugin’s failure_mode = "fail_open"
PII response legpii_streaming_unsupportedrolter_pii_stream_rejections_totalpii_sanitizer.streaming = "passthrough"

A plugin’s waiver is its own failure mode rather than a new setting. fail_closed already says “never deliver what I have not approved”, and a stream is exactly a response the plugin cannot approve. fail_open already says “deliver when I cannot answer”, which is all a stream permits. The check (handlers.rs, beside post_response_plugin_list) refuses when any applicable plugin is fail closed and names it in the message. Before #1776, every post_response plugin was silently skipped for a stream. Tests: the *_post_response_plugin_*stream* cases in crates/rolter-gateway/tests/integration.rs, including a cached stream refused after a hot reload adds the plugin.

The dashboard states the consequence where the choice is made (#2178). ui/src/pages/Plugins.tsx shows a note in the plugin dialog once the stage is post_response, worded by the selected failure_mode and wired as the description of both pickers, and a post_response card with fail_closed carries a one-line warning. Unlike the guardrail rule screen, which has to read streaming_post_call from the effective config, both follow from the plugin’s own fields. The stories WarnsThatAFailClosedPostResponsePluginRefusesStreams and DialogNoteTracksStageAndFailurePolicy pin it.

Failed-login throttling (#1079)

POST /api/v1/auth/login is unauthenticated and runs one argon2id verification per request. That cost is deliberate against guessing and accidental against denial of service: an attacker who does not care about the password can saturate the control plane by posting junk credentials. crates/rolter-control/src/login_throttle.rs counts rejected attempts and refuses further ones before the password is verified, which is what closes the CPU half rather than only the guessing half.

Four properties are load-bearing:

  • The counters key on the submitted address, never on a row that was found. An unregistered address is counted, delayed and locked exactly like a real one, so the throttle does not undo the constant-cost verification in LocalIdentityProvider::resolve by becoming an enumeration oracle.
  • A lock is a clock, not a state. It is a TTL nobody clears by hand. A lock an attacker could set would be a denial-of-service primitive against the operator — anyone who knows an email address could park the account. The worst case here is a bounded outage capped by --login-max-lock-secs.
  • Two independent subjects. The account budget stops guessing one password; the client-address budget stops spraying one guess across many accounts, which a per-account counter never sees. X-Forwarded-For names the client only when --login-trust-forwarded-for is set, because an unverified forwarded header hands out unlimited budgets and lets one client spend another’s.
  • A backend outage degrades to absent, not to locked. A redis error counts as zero failures. The alternative — treating an unreachable counter as a reason to refuse — would turn one redis blip into a fleet-wide lockout.

The same guard covers the invitation preview/accept endpoints, which are the same primitive with a different token.

Rejected attempts, engaged locks and sign-ins that follow a lock are written to audit_log, and rolter_control_login_attempts counts them by outcome (success, invalid, throttled, locked, error) with no account or address label. The audit write is spawned rather than awaited: resolving the org an entry belongs to costs a query that only exists when the account does, and awaiting it would make a registered address measurably slower to reject.

Counters live in redis when one is configured, so every replica shares a budget, and in-process otherwise. That fallback is documented rather than hidden: with N replicas and no redis, an attacker gets N times the allowance, and the control plane says so at startup.

Who reads account events (#1854)

Sign-ins, failed sign-ins, lockouts, second-factor changes, break-glass resets and account edits belong to a person, and a person can belong to several orgs, so these rows are written to audit_log with no org. Until #1854 the only read path, GET /api/v1/orgs/{org_id}/audit-log, filtered on the org column, so none of them reached an API or a screen.

The org read now joins on membership: a row with no org is returned to an org when its actor, or its target user, holds a role in the org, one of its teams or one of its projects. That puts an org member’s failed sign-ins, a second factor being removed and a break-glass reset in front of the people who administer that org, in Governance → Audit Logs, where the auth.* actions are filterable. It never shows one org another org’s people. user.delete is the exception to “written with no org”: it is written once per org the account belonged to, because the account’s memberships are deleted with it and the join would have nothing left to match.

The alternative was to write each account event once per org the person belongs to. It was not taken because it fans one sign-in out into a row per org, and it would leave every row already written unreadable, while the join makes a deployment’s existing history readable as soon as the control plane is upgraded. The cost is that visibility follows current membership: an org sees the account events of the people it has now, including events from before they joined, and stops seeing someone’s once they hold no role in it. Deactivation keeps memberships, so a deactivated leaver stays visible; user.delete, which removes them, is written per org for that reason.

Rows no org can claim are still not readable through the API: the account events of someone with no membership anywhere — above all a superadmin’s own sign-ins — and attempts against an address nobody registered, which are recorded with no actor and otherwise reach only the logs and rolter_control_login_attempts. A deployment-wide read for the superadmin (and the security-auditor role of #1834) is #1858.

Three credentials, one word (#943)

Three unrelated secrets pass through rolter, and each is an “API key” to someone. Keeping them apart is a security property, not a documentation nicety — a provider key pasted into a client is a credential leak that no budget, allow-list or audit row constrains.

CredentialShapeHeld byChecked by
virtual keysk-rolter-<48 hex>client applicationsthe gateway, on /v1/*
provider keythe provider’s own shaperolternothing — it is presented upstream
admin tokenoperator-chosenoperators, automationthe control plane

The gateway therefore refuses a provider-shaped key with a message that names the mistake (“this looks like an Anthropic provider key…”) rather than a bare invalid api key. provider_key_vendor in crates/rolter-gateway/src/handlers.rs does the shape match, and it never classifies our own sk-rolter- prefix: a revoked or mistyped virtual key is a different problem and gets the plain message. The hint is derived purely from the prefix — no lookup, no timing difference between a known and an unknown key, and the presented secret is never echoed back.

The dashboard follows the same vocabulary: Virtual Keys and My Virtual Keys mint the client credential, the provider sheet says Provider key, and the Playground’s key field says which one it wants. docs/user-docs/security/which-key is the user-facing version of this table.

The Playground does not ask for that key first. Opening it mints a session key through POST /api/v1/me/projects/{id}/playground-key (see RBAC and auth), and the dashboard holds the plaintext in a module variable in ui/src/lib/gateway.ts — never in localStorage, which is where it used to go and where a long-lived production key then sat until somebody cleared it (#944). The screen renders the key’s state, never the secret: a badge, the expiry, and a Renew key button that asks for a fresh key rather than extending the one in hand. The paste field stays for testing one specific key on purpose, and a key pasted there carries no expiry, because the dashboard did not choose one.

Threat model (high level)

  • Tenant isolation: virtual keys are scoped to a project and reach only their own org’s routes, providers and provider groups (#1844); model allow-lists prevent access to unconfigured models; cache keys are namespaced to avoid cross-tenant cache poisoning.
  • Offboarding: deactivating a person, or removing their last role that reaches a project, takes the keys they minted for themselves off every gateway, and deleting the account disables them (#1841); keys an admin minted for an application are the project’s and stay. See RBAC & authentication.
  • Abuse: RPM/TPM rate limits and budgets bound spend and load (roadmap enforcement); failed control-plane sign-ins are throttled per account and per client address (see above).
  • AuthZ: control-plane mutations are RBAC-checked and recorded in audit_log.
  • Supply chain: cargo deny/advisory scanning in CI is a roadmap item.

Operational guidance

  • Always set a strong ROLTER_KEK (e.g. openssl rand -hex 32) and rotate provider keys periodically.
  • Run the control plane on a private network; expose only the gateway publicly.
  • Back up Postgres; treat the master key as the most sensitive secret. It is not in the dump, so a database backup without it restores into a store nothing can read — rolter kek verify catches that at restore time, and the runbook covers backup, restore and rotation.

LDAP authentication

The third identity provider behind rolter_auth::IdentityProvider, after local password login and OIDC SSO (#241). It is compiled in behind the ldap cargo feature and is off unless configured.

How a login is verified

A user’s distinguished name is rarely their login name, so verification is a two-phase bind:

  1. Find. Bind as a read-only service account and search base_dn for the entry matching user_filter, with {login} substituted.
  2. Verify. Bind a second time as the entry that was found, using the password the user submitted. That bind is the authentication.

rolter never reads, compares or stores a password hash from the directory. It also never caches the password.

A filter matching more than one entry is a hard failure rather than a login: binding as an arbitrary one of several matches would authenticate the wrong person.

Group mapping is least-privilege

group_role_map maps a directory group onto a rolter role. It is explicit and additive:

  • a user in several mapped groups gets the union of their roles;
  • a user in no mapped group is rejected, even with a correct password.

There is no implicit default role and no wildcard. Authenticating proves who someone is, not what they may do — the same rule SSO already follows.

Group names are matched case-insensitively and with surrounding whitespace trimmed, because directories are inconsistent about DN case and an operator who copies a group from their directory’s UI should not silently get no access.

What a client is told

Every authentication failure returns the same generic rejection:

SituationResult
No such userNotVerified
Wrong passwordNotVerified
User outside base_dnNotVerified
Authenticated, but in no mapped groupNotVerified
Directory unreachable or misbehavingProvider("directory unavailable")

The first four are deliberately indistinguishable. A login form that distinguishes “no such user” from “wrong password” is a user-enumeration oracle against the corporate directory, which is a worse leak than the login itself.

A directory being down is distinguishable, because that is an operational fact the operator needs and reveals nothing about any account. Distinguished names, filters, bind credentials and raw LDAP result codes are logged for the operator and never returned.

Configuration

url = "ldaps://dir.example.com"          # ldaps:// strongly preferred
bind_dn = "cn=svc-rolter,dc=example,dc=com"
bind_password = "…"                       # read-only service account
base_dn = "ou=people,dc=example,dc=com"
user_filter = "(uid={login})"             # must contain {login}
email_attr = "mail"
display_name_attr = "cn"
group_attr = "memberOf"
require_tls = true

[group_role_map]
"cn=rolter-admins,ou=groups,dc=example,dc=com" = "admin"
"cn=rolter-users,ou=groups,dc=example,dc=com" = "member"

Configuration is validated before it is used to authenticate anyone. These are rejected at startup rather than at first login:

  • a URL that is neither ldap:// nor ldaps://;
  • require_tls with a plain ldap:// URL — the service-account bind password would cross the network in cleartext;
  • a user_filter without {login}, which would authenticate whoever the filter happens to match, for any login submitted;
  • an empty base_dn;
  • an empty group_role_map, under which no directory user could ever be granted a role.

Filter injection

The login is escaped per RFC 4515 before substitution. Without it a login of * matches every user in the subtree, and )(uid=admin rewrites the filter — LDAP injection, the directory analogue of SQL injection. \, *, (, ), / and NUL are escaped.

Operational limitations

  • Group membership is read from the user entry via group_attr (typically memberOf). Directories that do not populate a reverse-membership attribute need a nested group search, which is not implemented.
  • Nested groups are not expanded. A user in a group that is itself a member of a mapped group is not granted that role. Map the groups users are directly in.
  • No connection pooling. Each login opens and closes its connections. This is correct but not fast; an interactive login is not a hot path, and pooling bound connections has its own correctness hazards.
  • No referral chasing. A referral from the directory is not followed.
  • ldaps:// only for transport security. StartTLS on a plain ldap:// connection is not implemented.

Health and readiness

Both rolter binaries serve two operational endpoints that answer two different questions. They are unauthenticated, cheap, and safe to probe on every pod.

EndpointQuestionFailure means
GET /healthzIs the process alive and its runtime not wedged?Kubernetes kills and restarts the pod
GET /readyzCan this process actually serve traffic right now?Kubernetes removes the pod from the Service and nothing else

The words mean the same thing on rolter-gateway and rolter-control.

Why the split exists

Collapsing both probes onto one endpoint has no correct answer, only a choice of which failure to accept:

  • If the single endpoint answers as soon as Axum binds, Kubernetes marks the pod Ready before it can serve. A rolling update then routes dashboard and /internal/snapshot traffic — which every gateway in the fleet polls — at a control pod whose migrations have not run and whose pool has no connection.
  • If the single endpoint instead checks Postgres, a database blip fails the liveness probe and Kubernetes kills every control pod at once. A recoverable dependency outage becomes a restart storm, and the restarts make the outage worse by reconnecting a stampede at the recovering database.

Splitting the endpoints lets a dependency failure drain traffic without touching the process.

/healthz — liveness

Returns 200 OK with the body ok whenever the HTTP server is accepting connections. It checks no dependency: not Postgres, not Redis, not ClickHouse, not an upstream provider. That is the point — nothing outside the process may cause a restart.

/readyz — readiness

Gateway

Returns 200 OK normally, and 503 Service Unavailable with the body draining once the instance has been drained from the dashboard’s cluster screen — the state arrives on the /internal/snapshot response the gateway already polls. A drained gateway therefore leaves the Service and finishes its in-flight requests, instead of being killed with them in progress.

Control plane

Returns a JSON body describing each check:

{
  "status": "ready",
  "checks": {"database": "ok", "migrations": "ok", "kek": "ok"}
}

200 when status is ready, 503 otherwise. The checks are:

  • database — the Postgres pool hands out a connection within 2 seconds. The timeout is deliberately shorter than a typical probe timeoutSeconds so an exhausted pool answers a clean 503 rather than hanging until kubelet reports a timeout, which looks like a dead process.
  • migrations — every migration embedded in this binary is applied to the database. A pod running ahead of its schema would serve wrong answers on the CRUD API and /internal/snapshot, so it is not ready.
  • kek — ROLTER_KEK parses when it is set. A KEK set to an empty value seals nothing and decrypts nothing, so the pod cannot serve credentials. Reported as not configured (and not a failure) when the variable is unset.

Redis and ClickHouse are deliberately not checked. The control plane serves configuration without either — losing Redis costs the config-bump fan-out notification, losing ClickHouse costs the analytics screens — so their absence is degraded, not unready. Removing every control pod from the Service because analytics is down would be a strictly worse outage.

Running without --database-url (bootstrap config only), there is nothing to wait on and the answer is always ready.

The dashboard’s gateway pill

The status pill in every screen header (ui/src/components/ScreenHeader.tsx) polls the gateway’s /readyz every 30 seconds through the control plane’s /gw/* proxy (#1973).

/readyz is the one gateway signal every dashboard role can read: the proxy takes no admin token and the endpoint takes no key. /api/v1/cluster/nodes knows more but is superadmin-only, and /api/v1/health/* is provider health from ClickHouse, not the gateway’s. The probe sends no Authorization header.

readinessFrom in ui/src/lib/gateway.ts reads the answer strictly:

AnswerPill
200 with the body okhealthy (green dot, breathing)
503 with the body drainingdegraded: “gateway draining”, amber
502 with the proxy’s own {"error":{"message":…}} bodydown: “gateway unreachable”, red
anything else, a network error, or no answer within 10 sunknown, grey
no answer yetchecking, grey

A 200 that is not ok is an SPA fallback in front of the control plane, and a 502 that is not the proxy’s JSON comes from an ingress that could not reach the control plane. Both read as unknown, so the pill never sends an operator to the wrong process.

One failed check does not blank a known answer. gatewayHealthFrom in ui/src/lib/gateway-health.ts keeps it for up to one and a half poll intervals, without the pulse and with the answer’s time in the tooltip, then drops to unknown. The poll pauses while the tab is hidden, and the header’s refresh button re-asks along with everything else without spinning for the background poll.

What the pill cannot see:

  • other gateways behind the same ROLTER_GATEWAY_URL, and whether a gateway runs the current config version. Both need a fleet summary every signed-in caller can read (#2013).
  • an unreachable gateway host. The proxy’s HTTP client has no connect timeout, so a host that drops packets hangs the request instead of answering 502, and the pill reports unknown after its own 10 second timeout rather than down (#2014).

Probe wiring

charts/rolter wires this for you:

readinessProbe:
  httpGet: {path: /readyz, port: http}
livenessProbe:
  httpGet: {path: /healthz, port: http}
startupProbe:               # control plane only
  httpGet: {path: /healthz, port: http}

The startupProbe on the control plane holds liveness off while first-boot migrations run against a cold database, so a slow migration is not mistaken for a wedged process. Tune the periods under control.probes in values.yaml.

What to alert on

  • /readyz down for longer than a failover — the control plane cannot reach its database, or is running against a schema it has not migrated. Page.
  • /healthz flapping — the process itself is crashing or wedging. Page.
  • /readyz down on a single gateway — usually just a drained instance, which is expected during a rolling update. Do not page on this alone.

Observability

Metrics

  • The gateway exposes Prometheus metrics at GET /metrics: counters (rolter_requests_total, rolter_upstream_errors_total, rolter_auth_failures_total, reload/log/budget/rate-limit/retry/cooldown/health/breaker/scrape counters), gauges (rolter_config_version, rolter_breaker_entries), and per-model latency histograms — rolter_request_latency_ms (total) and rolter_request_ttft_ms (time-to-first-token), each labelled {model=...} with the standard _bucket/_sum/_count series. Histograms are observed once per completed request from the log sink, off the response hot path.
  • The exporter is hand-rolled (atomic counters + non-cumulative histogram buckets cumulated at render) rather than the metrics facade + global recorder, which does not fit the lock-free arc-swap design where an explicit Arc<Metrics> is threaded through the request path.
  • Passive per-target SLA signal: rolter_target_requests_total{provider,target,outcome} (a counter, outcome = ok for 2xx else error) is tallied once per completed request from the log sink — free, derived from real traffic, no extra upstream calls. A per-target error rate / uptime is sum(rate(rolter_target_requests_total{outcome="error"}[5m])) / sum(rate(rolter_target_requests_total[5m])). This is the first slice of provider stability tracking (ROL-123); the ClickHouse provider_health_events table and the dashboard land in later slices. The active prober is guarded: bounded probe concurrency with per-provider jitter, consecutive-failure/-recovery thresholds gating the unhealthy flip (no single-probe flapping), and exponential probe backoff when a probe itself gets a 429.
  • Client disconnects (#1083): rolter_client_disconnects_total counts requests whose caller left before the response completed, and rolter_inflight_requests is the live in-flight gauge those requests must return to zero. Abandoned requests are logged with status 499 and are never retried — see Client disconnects.
  • Provider queues (#1855): rolter_provider_queue_depth{provider} and rolter_provider_inflight{provider} gauges and the rolter_provider_queue_wait_ms{provider} histogram, kept by RAII guards in metrics.rs. A job carries a QueuedGuard from the moment it is built until a worker takes it (picked() observes the wait and hands over an InflightGuard) or it is dropped unrun, so a shed or stranded job can never leave the depth raised; the in-flight guard lives until the upstream’s response headers arrive, which is also when a worker is released, and covers the direct path when queueing is off. The steady-state lookup borrows the provider name, so the path allocates nothing. Over OTLP (#1862) the two gauges are a labelled gauge family (LABELLED_GAUGE_FAMILIES, registered as observable gauges beside the labelled counter families by register_labelled in rolter_core::telemetry), and each wait is also recorded into an OTLP rolter_provider_queue_wait_ms histogram on the Prometheus boundaries, through a per-provider QueueWaitRecorder whose provider attribute is built once when the provider’s counters are. The Redis consumers’ connection state (#1772) rides the same two families; see Redis connections. The rolter · gateway capacity board in integration/dogfood/signoz/dashboards/ charts all of it.
  • Billed but withheld (#1478): a response an output guardrail or post-response plugin refuses to deliver is still billed. Its row carries the caller’s 403, withheld = 1, the policy in error, and the provider’s tokens and cost, and rolter_withheld_responses_total counts it. usage_unknown = 1 marks any successful row whose upstream reported no usage. See Billed but withheld.
  • Multi-key providers: rolter_key_cooldowns_tripped_total counts api keys parked after a key-level failure (429/401 on a provider with several keys); the request retries in-flight on a sibling key.
  • A/B attribution: rolter_variant_requests_total{model,variant} (a counter) tallies requests per chosen variant, so traffic splits are visible in Prometheus/Grafana without querying ClickHouse. Classic single-pool routes (no variant) emit nothing. Observed from the same log-sink funnel (ROL-195, part of ROL-188).
  • Adaptive routing (#544): rolter_adaptive_routing_decisions_total{model,mode} splits a route’s picks between blend (the latency/cost/load blend), exploration (the bounded random share that keeps starved targets sampled) and fallback (the deterministic pipeline stack served while the kill switch is off or the evidence is too thin), and rolter_adaptive_routing_engaged{model} is 1 while the blend is actually routing. Only routes on the adaptive strategy emit these. A config reload rebuilds the balancer and so resets the counters, which lines up with a rolter_config_version bump — alert on rate(), not the absolute value. A route sitting at engaged 0 with all picks on fallback is the expected steady state before an operator enables the policy. rolter_adaptive_routing_target_score{model,target} (#751) adds the blended score each target currently carries — the same numbers the control plane serves at GET /api/v1/adaptive-routing-telemetry, computed at scrape time rather than on the request path.
  • Roadmap: add per-provider/route labels on the histograms, in-flight gauges, cache-hit ratio, and circuit-breaker state gauges.
  • Roadmap: scrape/federate upstream engine metrics from vLLM/SGLang/TGI /metrics and correlate them per target (queue depth, KV-cache usage, running/waiting requests) to feed load- and cache-aware routing and the dashboard.

Where request logs go (#929)

The dashboard’s Usage, Costs and Logs screens read ClickHouse through the control plane. The rows they read are written by the gateway. For a long time nothing connected the two: the control plane took CLICKHOUSE_URL, the gateway took [logging].clickhouse_url from its own bootstrap TOML, and a deployment that set only the first one logged nowhere while the screens queried a table nobody filled. The snapshot even carried the field, as "clickhouse_url": null, so the control plane was telling every gateway to log nowhere.

There is now one variable and one order of precedence. The gateway resolves its destination, at startup, as:

  1. [logging].clickhouse_url in its own config file — an explicit local decision is never overridden.
  2. CLICKHOUSE_URL in its environment. Both processes read this one variable, which is what a compose file, a Helm release or rolter init sets.
  3. The value the control plane publishes in /internal/snapshot, when the gateway is started with --snapshot-url. The control plane puts its own CLICKHOUSE_URL there, so a fleet writes where the dashboard reads without anyone configuring the gateways individually.

Step 3 is read before AppState is built, not on the first poll, because the ClickHouse writer is spawned once at startup — the hot-swappable snapshot carries routing, not background tasks, so a destination arriving on a later poll would arrive too late to open a sink. It is best-effort by construction: a control plane that is not up yet costs one five-second timeout and the gateway starts exactly as it did before.

When all three come up empty on a gateway that has a control plane, startup logs a warning naming both fixes. An empty analytics screen and a quiet deployment look identical, and rolter check says the same thing before anything starts.

Tracing & context propagation

  • tracing + tracing-subscriber with RUST_LOG filtering; TraceLayer logs each HTTP request.
  • Inbound: accept W3C traceparent/tracestate (and b3) from clients and continue the trace; honor x-request-id / x-correlation-id.
  • Outbound to engines: inject the active trace context into upstream requests so vLLM/SGLang/TGI spans join the same distributed trace. vLLM and SGLang support OpenTelemetry tracing (e.g. vLLM --otlp-traces-endpoint); point them at the same OTLP collector so engine prefill/decode spans line up with rolter’s request span.
  • A per-request request_id is echoed in a response header and stamped on logs, metric exemplars and spans for correlation.
  • Events: rolter calls no span-event API directly, but tracing-opentelemetry turns every tracing event fired inside an active span into an OTel span event. That API is deprecated upstream in favour of log-based events; the target model, and the sequencing it forces, are recorded in ADR-0025.

How the context actually moves

rolter-core::telemetry owns both directions, and both are inert unless an OTLP endpoint is configured:

  • Extract. GatewayMakeSpan (rolter-gateway::trace) is the TraceLayer’s span-maker: it builds the request span and makes the extracted inbound context its parent. A B3-only caller is normalized into an equivalent traceparent first, so one W3C propagator serves both wire formats. Without this the gateway’s spans were disconnected roots — the trace id reached the request log, but nothing joined the caller’s trace.

    It has to be the span-maker rather than a middleware layered inside the TraceLayer: DefaultMakeSpan builds the request span at DEBUG, so under the default RUST_LOG=info it is disabled, and setting a parent on a disabled span silently does nothing. With no pipeline installed it falls back to that stock DEBUG span, so the untraced path costs what it always did.

  • Inject. The context handed to the provider is injected from the current span, inside the per-attempt upstream.request span, rather than copied from the caller. Copying it verbatim made the provider call a child of the caller’s span and therefore a sibling of the gateway’s own work, which silently invalidated every waterfall built from the data. The allowlisted client headers from Forwarder::forwarded_header_names are unaffected; only the trace headers changed hands.

  • Log correlation. RequestLog.trace_id is read off the span context when a pipeline is installed and falls back to parsing the inbound header otherwise, so ClickHouse and the trace backend agree by construction.

Pipeline spans

One span per stage, so a slow request is attributable rather than merely slow:

SpanAttributes
auth—
guardrails.preredacted, webhook
route.selectroute, strategy, candidates
cache.lookuphit, kind (exact / semantic)
queue.waitprovider
upstream.requestattempt, gen_ai.system, gen_ai.request.model, gen_ai.response.id, http.response.status_code (embeddings also carry gen_ai.request.encoding_formats and gen_ai.embeddings.dimension.count)
translate.request—
guardrails.post—

queue.wait spans enqueue→dequeue only: the span travels with the queued job and the worker closes it the moment it picks the job up, so it measures the wait and not the wait plus the upstream call. The job carries the caller’s span alongside it, and the worker instruments the forward with it — the queue worker runs on its own task where nothing is in scope, so without that the forwarder’s own translate.request span becomes an orphan root in a trace of its own. Names follow the OTel GenAI semantic conventions where they fit, so a backend’s built-in GenAI views work.

Spans never carry prompt or completion content, API keys, virtual-key plaintext, or injected header values — those are credential material, and redaction stays owned by the existing logging_settings machinery rather than a second policy.

Control-plane spans

The control plane runs the same pipelines as the gateway (telemetry::init()), but until #845 emitted no spans of its own — everything it did was invisible beyond what the HTTP layer produced by default.

SpanAttributes
control.requesthttp.route, http.request.method, http.response.status_code
snapshot.buildconfig_version, payload_bytes, outcome
snapshot.sanitize—
snapshot.encode—

http.route is the matched template (/api/v1/providers/{id}), never the concrete path. control.request comes from one middleware rather than an attribute on each of ~90 handlers, so a route added tomorrow is instrumented the moment it is mounted.

snapshot.build is the one to watch. Snapshot latency is fleet-wide config-propagation delay: every gateway waits on it, so when an operator changes a route and the fleet serves stale config, this span is what says whether the delay is in generation or downstream of it. config_version on the span answers “which config was this” without putting an unbounded value on a metric.

Tenant attributes

The gateway.request span carries rolter.org.id, rolter.team.id and rolter.project.id, recorded once the virtual key resolves. The span itself is built by the tower layer, which runs before auth, so the fields start empty and are filled in rather than passed at construction.

These exist so per-tenant telemetry destinations are routable. ADR-0026 decided that fan-out to tenant-owned backends belongs in an OpenTelemetry Collector rather than in-process exporters — one egress path in the gateway no matter how many tenants, and no data-plane process POSTing to operator-supplied URLs — and that rolter’s job in that design is to stamp the attribute the collector routes on. Until this landed there was nothing to stamp: the request logs in ClickHouse always carried tenant identity, but no exported span ever did, which made the routing half unimplementable.

An unattributed request — a config-defined key, which has no org — records nothing rather than an empty string. An attribute that is present-but-blank on some spans and absent on others is harder to write a routing rule against than one that is consistently absent.

The names are deliberately rolter-local. The GenAI and HTTP conventions define nothing for tenancy, and a convention-shaped guess like tenant.id would be worse than an obviously-local name if the spec later defines it differently.

Running it locally

The observability overlay starts a collector and a trace UI alongside the normal stack:

docker compose -f docker/docker-compose.yml \
               -f docker/docker-compose.observability.yml up

Traces land at http://localhost:16686; the collector takes OTLP on 4317/4318 and re-exposes collected metrics on 8889. The overlay sets OTEL_EXPORTER_OTLP_ENDPOINT on the gateway and control services, so bringing it up is the only step — without it that variable is unset and tracing stays off.

The collector binds 0.0.0.0, not localhost: one bound to loopback inside its container is unreachable from the gateway container.

Choosing an overlay

There are two, and they are mutually exclusive — both publish OTLP on 4317/4318.

docker-compose.observability.yml (default)docker-compose.signoz.yml
backendJaeger v2SigNoz
signalstraces onlytraces, metrics, logs
containers25, incl. its own ClickHouse + Zookeeper
storagein memory, lost on restartpersistent
use it forreading a waterfall, fast iterationaggregate views, dashboards, dogfooding over time
docker compose -f docker/docker-compose.yml \
               -f docker/docker-compose.signoz.yml up      # SigNoz on :8080

Jaeger is the default because it is two containers and starts in seconds, and because reading a correctly-parented waterfall is what the tracing work needed. Reach for SigNoz when “which stage is slow across all requests” matters, which Jaeger cannot answer.

Nothing in the Rust differs between them: the gateway speaks vendor-neutral OTLP and only the destination changes. Any other OTLP backend works the same way — repoint the exporter in infra/otel/collector.compose.yaml.

Querying SigNoz from an agent (MCP)

The SigNoz overlay also starts SigNoz’s MCP server on http://localhost:8000/mcp, so an agent can query traces, run ClickHouse queries, and manage dashboards against the local instance. Point an MCP client at that URL; the SigNoz agent-skills plugin ships a signoz server entry to fill in.

It needs a SigNoz API key, which is created in the UI (Settings → API Keys, admin only) and supplied to the server, not the client:

set -x SIGNOZ_API_KEY (pass show rolter/signoz-api-key)
docker compose -f docker/docker-compose.yml \
               -f docker/docker-compose.signoz.yml up -d signoz-mcp

The key is read from the environment and never written to a tracked file. With no key set the container still starts, but every call returns Authorization or SIGNOZ-API-KEY header required.

The MCP dashboard tools need SigNoz v0.135.0 or newer, which is why the signoz image is pinned ahead of the collector/ClickHouse pair.

The SigNoz overlay is a pinned equivalent of what SigNoz’s Foundry CLI generates, since SigNoz deprecated its own compose manifests in v0.130.0. Two things to know before touching it: its four images are a tested set and must be bumped together (a newer migrator emits ClickHouse settings an older server rejects), and it vendors 3.6 KB of ClickHouse config — the cluster topology and the {shard}/{replica} macros that ReplicatedMergeTree needs — rather than SigNoz’s full 56 KB config.xml, which the stock image defaults cover.

Metrics over OTLP

The counters and gauges rolter-gateway::metrics computes are exported over OTLP as well as served on /metrics. The Prometheus endpoint is unchanged — this is a second exporter over the same numbers.

Both read one list, Metrics::scalars(), so they cannot drift: a counter added there reaches Prometheus and OTLP without a second edit. Counters export as counters and gauges as gauges, since exporting a counter as a gauge would break rate() on the backend.

The instruments are observable: nothing is pushed on the request path. The SDK invokes the callback on its own schedule (OTEL_METRIC_EXPORT_INTERVAL, default 60s) and reads the same atomics the Prometheus renderer does, so the hot path still only does fetch_add. With no OTLP endpoint configured no meter provider, exporter or callback is built at all.

Per-model histograms and label-bearing counters stay Prometheus-only for now; the scalar set is what OTLP carries.

Control-plane metrics

The control plane has no Prometheus registry to mirror, so these are the one place it measures itself (#845). They are real histograms — measurements taken as they happen — not observable instruments, because a duration cannot be reconstructed from a counter after the fact.

MetricUnitAttributes
rolter_snapshot_build_msmsoutcome (ok / not_modified / error)
rolter_snapshot_payload_bytesByoutcome
rolter_control_request_msmshttp.route, http.request.method, http.response.status_class
rolter_db_pool_acquire_msmsoutcome (ok / timeout)

And one counter, for the endpoint an unauthenticated attacker can reach (#1079):

MetricMeaningAttributes
rolter_control_login_attemptsresolved control-plane sign-in attemptsoutcome (success / invalid / throttled / locked / error / mfa_challenge / mfa_invalid / mfa_enrolment / mfa_required)

A counter rather than a histogram: the question it answers — “is somebody running a credential-stuffing run against this deployment” — is a rate, not a distribution. It carries no account or address label; either would be unbounded cardinality and would put the identity an attacker is guessing into the metrics pipeline. A rising invalid with a rising locked behind it is the throttle working; a rising invalid with no locked means the run is spread thin enough to stay inside the per-account budget, and the per-address budget is the one to tighten.

And one for the telemetry the control plane ingests but cannot store (#1747):

MetricMeaningAttributes
rolter_control_ingest_failuresUX-event or MCP-log writes that were not storedstream (ui_events / mcp_logs), reason (insert / unconfigured)

Both callers swallow the failure by design — the dashboard drops the batch and keeps flushing — so without this a missing ui_events table loses the whole UX stream with no signal at either end. insert is a store that refused or could not be reached; unconfigured is a control plane with no CLICKHOUSE_URL. The store’s error text is deliberately not a label (it is unbounded); it is in the matching telemetry ingest failed warning, which crates/rolter-control/src/ingest_failure.rs rate-limits to one per minute per stream and which carries a suppressed count of the failures it stands for. Any sustained non-zero rate is worth an alert.

And the connection pool, as observable gauges (#1052):

MetricMeaning
rolter_db_pool_connectionsconnections the pool holds open
rolter_db_pool_idlehow many of those are free right now
rolter_db_pool_maxthe configured ceiling

One pool serves /internal/snapshot, the whole CRUD surface and every RBAC membership lookup, so a ceiling that is too low presents as “the control plane got slow” with nothing to attribute it to. The three gauges together are what separate the two cases: pool-bound is connections == max while idle is zero and rolter_db_pool_acquire_ms shows waits; acquire waits without a pinned pool mean the database itself is slow, and raising the ceiling there makes it worse.

rolter_db_pool_acquire_ms is sampled on a 15-second timer rather than instrumented per call. Wrapping every acquire() would mean touching every repository method to answer a question that is a distribution over time, not a per-request fact. The probe takes one connection and drops it: if that is disruptive, the pool is already far too small, and that is the finding.

Boundaries are deliberately not the gateway’s. A gateway request is dominated by an upstream model call and is interesting out to tens of seconds; a snapshot build and a CRUD write are database work where “is this 2 ms or 40 ms” is the whole question, so reusing the gateway’s boundaries would put nearly every observation in the first bucket.

Cardinality is bounded by construction. http.route is the matched template, so a thousand providers are one series. Status is recorded as a class, not a code: twelve statuses across ninety routes would be over a thousand series to answer a question five buckets answer. config_version is unbounded — a new value on every config write — so it lives on the snapshot.build span and never on a metric.

rolter_snapshot_payload_bytes skips the 304 case rather than recording a zero: a not-modified poll transfers no body, and folding zeroes in would drag the size distribution down and misreport what the fleet actually moves.

Payload size matters on its own. It is what every gateway transfers on every poll, and it is the first thing to look at when propagation gets slower without generation getting slower. The snapshot bench in rolter-core shows why the encode is the stage to watch: at 1000 routes it costs ~2.8 ms against ~150 µs for sanitize and ~120 µs for validate.

The control plane also registers the same process and runtime metrics as the gateway — it degrades for the same reasons and previously answered none of those questions either.

Logs over OTLP

Logs export alongside traces and metrics, gated on the same environment (OTEL_EXPORTER_OTLP_ENDPOINT, or the logs-specific OTEL_EXPORTER_OTLP_LOGS_ENDPOINT). With neither set no exporter is built and logging is stdout-only, exactly as before.

It is additive, not a replacement: the stdout fmt layer is untouched, so docker logs shows what it always did and the collector additionally receives the same records.

Records carry trace_id and span_id, which is most of the value — a log joins the trace it came from instead of being a separate pile of text. tracing-opentelemetry publishes the OpenTelemetry context when a span is entered, and the logs SDK stamps it onto each record. That correlation is asserted by a unit test rather than assumed from a dependency default, since it would fail silently if the default ever changed.

Redaction is unaffected: this exports the same tracing events the stdout layer renders, so anything already kept out of logs stays out of them.

Process metrics

Alongside the domain counters, the process reports its own vitals: resident and virtual memory, CPU time, open file descriptors, thread count and uptime. These are the numbers an operator reaches for first when a node degrades, and the gateway previously answered none of them.

They are observable instruments read from /proc, so nothing touches the request path. On a platform where /proc is unavailable the instruments are not registered at all, rather than registered and always zero — a metric that reads zero forever is worse than an absent one, because a dashboard cannot tell it from a healthy process. Names follow the OTel process.* conventions.

Runtime metrics

The process metrics describe the machine; these describe the scheduler. tokio.runtime.queue.depth is the one that matters: when latency rises, no other signal separates “the provider is slow” from “the request sat in our own run queue before we ever called the provider”, and the two have opposite fixes. It pairs directly with the queue.wait span. Alongside it are tokio.runtime.workers, tokio.runtime.tasks.alive and tokio.runtime.worker.busy.time.

Busy time is exported, not a busy ratio: a ratio computed in-process would average over whatever interval the SDK happens to use and would not re-aggregate across instances. As a monotonic counter the backend derives utilisation with rate(tokio.runtime.worker.busy.time) / tokio.runtime.workers, which does.

None of this requires --cfg tokio_unstable. #834 assumed it did, and that is true only of part of tokio’s surface: num_workers, num_alive_tasks and global_queue_depth are stable, and worker_total_busy_duration sits behind cfg_64bit_metrics!, which is #[cfg(target_has_atomic = "64")] — a property of the target, not an instability gate. What remains genuinely gated is the blocking pool and the per-worker steal/poll counters, which are therefore not exported; the workspace-wide flag decision stays unmade rather than being smuggled in with a telemetry change.

Outside an async runtime the instruments are not registered, for the same reason the process metrics are absent without /proc.

Connection-pool metrics

rolter_upstream_connections_total and rolter_upstream_connect_errors_total count connections established to providers, and attempts that never got that far.

Pool exhaustion presents as latency with healthy providers — the failure mode the other metrics cannot explain. reqwest and hyper expose no pool introspection at all, so this is instrumented rather than read: a tower layer over the connector (ClientBuilder::connector_layer) sees every connection hyper builds because it had none to reuse. The signal is the ratio rate(rolter_upstream_connections_total) / rate(rolter_requests_total) — near zero when the pool is working, approaching one when every request is paying a fresh TCP and TLS handshake.

Idle-versus-active counts stay unavailable: that needs the connection object’s drop, and the connector layer’s response type is opaque outside reqwest.

Resource attributes

Every signal carries service.name, service.version (the crate version), and where configured service.instance.id and deployment.environment.name. OTEL_RESOURCE_ATTRIBUTES is honoured by the SDK for anything else.

service.instance.id is the very same value the cluster watcher sends, read from rolter_core::node_identity rather than derived a second time, so a node in cluster_nodes and a node in the trace backend are the same node by construction. The precedence is ROLTER_NODE_ID, then HOSTNAME, then the host’s own name from gethostname (#1644) — the syscall is last so an operator who names a replica keeps that name, and it exists because a gateway started from a shell, a systemd unit or a launchd job has no HOSTNAME in its environment. When even the syscall fails the attribute is omitted rather than invented per restart, which would churn the identity on every deploy. A value the control plane’s ingest would reject — blank, longer than 128 bytes, or carrying a control character — counts as not resolved, so nothing is ever reported under an id that is discarded on arrival.

Wrapping audit (#815)

OpenTelemetry’s Don’t wrap OpenTelemetry argues that a house abstraction over the instrumentation API costs performance, maintainability and developer education. Three anti-patterns are named: wrappers that force callers to allocate an attribute collection, wrappers that look an instrument up by name per measurement, and general API abstraction that teaches a proprietary interface instead of the standard.

rolter has four things sitting between its code and the OTel API. Each was audited against that post; the verdict is keep for all four, for the reasons below. The post is guidance rather than a mandate, and it asks for a refactor only where there is a measured cost or a real maintenance burden.

Note up front that rolter instruments through tracing + tracing-opentelemetry. That is the ecosystem bridge, not a bespoke house wrapper, and it is not what the post argues against. This audit is not a proposal to remove tracing.

ItemVerdictWhy
stage_span! (rolter-core/src/telemetry.rs)keepcode generation, not a runtime wrapper
The scalar-metrics list (Metrics::scalars())keepthe by-name lookup is on the export path, not the request path
RequestHistograms::recordkeepone unavoidable allocation; the alternative is the anti-pattern
GatewayMakeSpan (rolter-gateway/src/trace.rs)keepSDK/layer configuration, explicitly out of scope

stage_span! expands to a direct tracing::info_span! call guarded by an is_active() check, so it is closer to the code generation the post recommends than to a runtime wrapper. It takes tracing’s own compile-time field syntax and never asks a caller to build a Vec or a slice of attributes, so the force-allocation anti-pattern does not apply. The guard is the point of the macro: with no pipeline installed it yields Span::none(), which allocates nothing.

The scalar-metrics list is the shape most at risk, since install_metrics registers one observable instrument per scalar and each instrument’s callback calls collect() and finds its own entry by name. That is a by-name lookup, but it is not on a hot path: the instruments are observable, so the SDK invokes those callbacks on its own export interval (OTEL_METRIC_EXPORT_INTERVAL, default 60s). The request path only ever does fetch_add on a named AtomicU64 field — there is no map, no lookup and no lock between a request and its counter. The post’s performance argument therefore does not bite here even though the gateway hot path is where it would bite hardest.

What the export path does cost is one Vec<ScalarMetric> allocation per instrument per cycle and a linear scan of it, so the work is quadratic in the number of scalars. At the current eight scalars, once a minute, that is immaterial. It is left as-is deliberately: the OTel Rust 0.33 API offers only per-instrument with_callback, so collecting once per cycle for all instruments is not expressible, and matching by name rather than by index keeps the callbacks independent of the order scalars() happens to return.

RequestHistograms::record is the one place a wrapper does force an allocation on the request path — model.to_string(), to build the single KeyValue both histograms take. It is unavoidable rather than incidental: OTel’s Value::String holds an owned or 'static string and model names are neither. The obvious way to avoid it is to cache a prebuilt attribute set per model, which would put a sharded-lock map lookup on the request path — precisely the lookup-based anti-pattern the post names, and something AGENTS.md forbids on the data-plane hot path. One small allocation is the cheaper of the two, and it is paid only when an OTLP endpoint is configured.

GatewayMakeSpan is a tower_http::trace::MakeSpan implementation: layer configuration, which the post explicitly separates from instrumentation and calls not wrapping. Recorded here only so the audit is complete.

Turning telemetry off explicitly

ROLTER_TELEMETRY_ENABLED=false hard-disables every export — traces, metrics and the dashboard’s browser tracing — regardless of which OTEL_* endpoints are set (#812). Unset means enabled, which changes nothing for an existing deployment: with no endpoint configured nothing is exported anyway.

The switch can only subtract. It never turns export on by itself, and an unrecognized value leaves export on rather than silently blinding a deployment; only 0, false, no and off disable it.

It exists because “off” was previously implicit — achieved by leaving an endpoint unset — which does not survive somebody setting the endpoint for one signal and gives an operator nothing to point at in a security review. It is deliberately environment-only and has no config-file equivalent; see ADR-0026, which also records why per-tenant telemetry destinations belong in the collector rather than in rolter.

Cost when tracing is off

With no OTEL_EXPORTER_OTLP_ENDPOINT (the default) behaviour and hot-path cost are unchanged: telemetry::is_active() is a single relaxed atomic load, stage spans are Span::none() (no allocation, and instrumenting a future with one is a no-op), no carrier is built, and outbound trace headers are copied verbatim exactly as before.

Exporters (OTel-compatible)

rolter emits traces and metrics via OpenTelemetry OTLP (gRPC/HTTP), so any OTel-compatible backend works without code changes — just set an endpoint and headers:

  • SigNoz, Grafana Tempo/Mimir, Honeycomb, Datadog (OTLP intake or the OTel Collector datadog exporter).
  • Langfuse for LLM-specific observability (prompt/response, token usage and cost as traces), ingested via its OTLP endpoint or SDK.

Recommended topology: rolter → OpenTelemetry Collector → fan-out to the chosen backends. The collector also scrapes the upstream engines’ /metrics and rolter’s /metrics, keeping vendor specifics out of rolter. Configure via env, e.g. OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS, OTEL_SERVICE_NAME=rolter-gateway.

infra/otel/collector.yaml is a ready-to-run local collector example: it accepts OTLP/gRPC and OTLP/HTTP, scrapes the compose gateway’s Prometheus endpoint, exposes collected metrics on :8889, and prints telemetry via the debug exporter. It intentionally has no external backend configured, keeping the default example safe for air-gapped use. Replace the debug exporter with an internal OTLP-compatible destination for production.

Connector delivery: rendered collector config (#836)

The observability_connectors table (#511, docs/dev-docs/architecture/data-model.md) lists the sinks a deployment wants its telemetry shipped to in addition to ClickHouse, but the connector’s endpoint is never called directly by rolter. Consistent with ADR-0026 — fan-out belongs in the collector, not N in-process SDK exporters each carrying its own connection/queue/retry buffer, and terminating an operator-supplied URL in the collector keeps that SSRF surface out of the data plane — GET /api/v1/connectors/collector-config (superadmin-only) renders an OpenTelemetry Collector config document from the enabled rows instead: one otlphttp exporter and one traces/metrics/logs pipeline set per connector, receiving from the otlp receiver rolter’s own OTEL_EXPORTER_OTLP_* export targets.

The endpoint answers a superadmin principal only, and a collector has no session to present one, so a collector is not pointed at it: that would put the admin token in the collector’s own deployment. The dashboard’s Collector config dialog (#1195, #2106) shows the document and the full endpoint URL, built from the control plane’s public base, and the operator saves the document as the collector’s config file, or an automation fetches it with its own credential and reloads the collector. A connector that is switched off is left out of the document, and the dashboard creates connectors switched off unless its add sheet’s start switch is on (#2349). sampling_rate becomes a probabilistic_sampler processor scoped to that connector’s own pipeline, since sampling is now the collector’s decision, applied independently per destination rather than once for the whole deployment. A managed secret (managed_auth_secret) is decrypted server-side and rendered as a literal Authorization: Bearer header; an external reference (auth_secret_ref) instead renders as a ${env:...} placeholder the collector’s own environment must resolve, since rolter never holds that secret’s value.

ROLTER_TELEMETRY_ENABLED wins over every connector row: when it is off, the rendered document has no exporters and no pipelines regardless of what is enabled in the registry, the same way it already silences every in-process exporter.

Backpressure to a slow or dead sink is the collector’s problem now, not rolter’s: it already queues and retries per exporter, which is the reason fan-out moved here in the first place rather than something this endpoint needs to build.

The remaining sink kinds from #511 (Datadog, Prometheus remote-write, Langfuse) become an exporter-block case in this renderer, not a new in-process delivery adapter — widening KINDS in connectors.rs and the kind check constraint in migration 0062 is the only rolter-side change; everything else is collector configuration.

Alert evaluation and delivery (#1871)

crates/rolter-control/src/alerting.rs owns alerting end to end: channels, rules, a 60-second evaluator started by every control-plane process that has a database, and the alert_notification_history table (migration 0025). The user-facing behaviour is in docs/user-docs/observability/alerting.mdx; this section is the reasoning behind it.

One evaluation is one transaction around the rule’s row lock. The ClickHouse read runs first, unlocked and bounded to 30 seconds, because it is the slow part. Then the evaluation takes select … for key share on the rule’s channel, select … for update on the rule, decides whether the reading is a transition, POSTs it, writes the rule and the history row, and commits. The scheduled pass takes the rule lock with skip locked and and enabled, so a rule another replica is evaluating is left to that replica and a rule disabled mid-pass is skipped; Evaluate now waits for the lock instead. That is what keeps control.replicaCount > 1 from reporting each transition once per replica. An edit that lands between the read and the lock and changes the signal, window or channel discards the reading, since it measures a query the rule no longer asks for or would go to a channel that was not locked.

The channel is locked before the rule because deleting a channel takes the locks in that order: the delete locks the channel row, then its on delete set null locks every rule naming it. With the evaluator taking the rule first and meeting the channel only at the history insert’s foreign-key check, a delete issued during a POST deadlocked with it (40P01), and either the delete returned a 500 or the evaluation rolled back after the receiver already had the alert. In the same order, the delete simply waits for the delivery to commit. key share rather than share keeps the channel editable meanwhile, and update_channel locks with for no key update so an edit does not queue behind a delivery either.

An older reading never overwrites a newer one. The unlocked read also takes clock_timestamp(), the database clock rather than the replica’s, as the reading’s time, and that is what last_evaluated_at stores. Once the rule is locked, a reading older than the rule’s last_evaluated_at is dropped: another evaluation started later and has already recorded its result, so applying the older one would report a transition pair that never happened. This matters most for a query that runs into the 30-second bound while a later pass succeeds.

A transition is decided against the history, not the state column. state also holds unknown and error, and neither says what an operator was last told. The newest history row does: firing is reported when the reading is high and the last row is not firing, resolved when the reading is low and the last row is firing. That makes three cases fall out without special handling: a rule enabled while its condition holds fires on its first pass; an evaluation error between two high readings does not fire twice; and a condition that cleared while evaluation was failing still resolves. It also makes a crash between the rule update and the history insert self-healing, since the next pass sees no row and reports again. The history insert uses clock_timestamp() rather than the column’s now() default, because now() is the transaction’s start and a transaction that waited on the lock started before the one it waited for.

Delivery never fails an evaluation. Every way a POST can go wrong becomes a failed row with a short detail (the HTTP status, or timed out, could not connect, endpoint denied by the egress policy, channel secret could not be unsealed, channel secret is not a valid header value), and the rule’s state still moves. The response body is never read, since a receiver’s error body can echo the bearer secret it just rejected.

A state that was never delivered is retried. One dropped POST at the moment a rule fires would otherwise mean no page for the whole incident, since every later pass reads firing against a newest row of firing. So transition() treats a holding state as unreported while every row for it since the last change is failed or skipped, and sends it again once the rule has an enabled channel. Each attempt is a new history row with a new id. The scheduled pass spaces attempts out by the number that failed: the next pass, then 2, 4, 8, 16 and 32 passes on, then hourly (retry_delay), so a dead endpoint writes a handful of rows an hour rather than one a minute, and the retries stop at the first delivered row or at the next change of state. A skipped row costs no delay, so a transition recorded while the channel was off goes out on the first pass after it is switched on or attached, and a rule with no enabled channel writes nothing more. Evaluate now retries without waiting, as an explicit operator request.

The delivery client is a dedicated reqwest::Client with redirects off, a 5-second connect and a 10-second total timeout. Redirects are off for the same reason as MCP OAuth discovery: a 3xx is how a host that passed the egress check hands the request to one that would not have. The endpoint is checked with EgressPolicy::check_url at save time and again before each POST, which matches connectors, and is stored as reqwest::Url serializes it, so the check and the request read one spelling of the host. A denial is logged with the channel id and the reason, never the endpoint, whose query string can carry a token. A save that only switches a channel off, with the endpoint unchanged, skips the check, so a channel a since-tightened policy denies can still be disabled. Nothing classifies what a hostname resolves to at connect time; that gap is shared by every control-plane outbound client and tracked in #1949.

A channel secret is bound to the endpoint’s origin. update_channel clears the sealed columns when the new endpoint’s scheme, host or port differs from the stored one and the request carries no new managed_secret; otherwise anyone with alert_channel:update could repoint a channel and receive its bearer secret. The audit entry records endpoint_changed, secret_replaced and secret_cleared, never the endpoint. A secret must also pass HeaderValue::from_str as Bearer <secret> at save time, since a trailing newline from a file would otherwise fail every delivery with no hint.

A failed evaluation writes state = 'error' and a last_error that is safe to show: a transport error is reduced to its class (reqwest’s message carries the URL, and CLICKHOUSE_URL can hold a password), while a ClickHouse error response keeps its status and exception text, cut at 512 bytes. The raw error goes to the log as alert signal query failed.

Request & cost logs

  • Every proxied request is logged to ClickHouse (request_logs): identifiers, model, provider/target, status, token counts, cost_usd, latency, TTFT, cache flag, error.
  • Sampling (logging_settings.sample_rate, default 1): LogSink::enqueue drops a row whose request-id hash bucket is at or above the rate (should_sample_request in crates/rolter-gateway/src/logging.rs) before it reaches the ClickHouse channel. LogSink::observe, the budget spend recorder and the rate-limit token recorder all run before that decision, so budgets, rate limits and /metrics count every request. The rate is not stored on the row and no read scales by it: below 1 the analytics endpoints, /api/v1/me/usage and the request_volume / spend_velocity alert signals return the sampled share (scaling them is #2239 and #2278). The Logs Settings screen warns with the resulting share whenever the form holds a rate below 100 % (#2088).
  • ts is the instant the request began — the same instant latency_ms is measured from, and the one the passive health event derived from that request carries. It is reconstructed from the request’s monotonic start (Instant), so a clock step during a long request cannot reorder rows, and it is written by the gateway as an RFC 3339 literal at the column’s millisecond precision (the insert asks ClickHouse for date_time_input_format=best_effort so that literal parses into DateTime64(3)). Add latency_ms to ts for the completion time. The same rule applies to the matching request_payloads row, which copies its request’s ts.
  • Gateways older than #1210 did not write ts at all and let the column’s default now64(3) stamp it, which recorded the batch flush time: every row in one flush shared a single millisecond, bursts collapsed onto one point, timeseries buckets were skewed by the flush interval, and keyset paging by (ts, request_id) had no order within a batch. The column default is kept as a fallback for those writers, so historical rows and any pre-#1210 gateway still land — but on such rows ts means flush time, not request time.
  • Retention defaults to 90 days for metadata and seven days for captured payloads, set as the TTL in the ClickHouse schema. Both are admin-managed: PUT /api/v1/logging-settings accepts retention_days (1–3650) and payload_retention_hours (1–8760) and issues the matching alter table … modify ttl against ClickHouse, which then expires parts on its own schedule. Payload retention may not exceed metadata retention, so raw prompt bodies never outlive the row they belong to. A ClickHouse failure leaves the stored policy in place and is logged rather than failing the admin write — re-saving reapplies it.
  • Payload capture is disabled by default. Set [logging.payload_capture] enabled = true to write redacted request and response payloads to the separate request_payloads table, which has a seven-day TTL (versus 90 days for request metadata). max_bytes bounds each body; redact_fields adds recursively redacted JSON keys before storage. Optional models and virtual_key_ids allow-lists make the deployment-level switch route- or key-specific.
  • Request id / trace continuation: every request carries an x-request-id — the caller’s when supplied, otherwise a generated UUID — which is echoed on the response and stored on the log row for end-to-end correlation. An inbound W3C traceparent or B3 (b3 / x-b3-traceid) header is parsed and its trace id stored in request_logs.trace_id, so gateway logs join the caller’s distributed trace instead of starting a disconnected one.
  • Outbound propagation: when the caller sent trace context, it is forwarded verbatim to the chosen upstream (traceparent, tracestate, and the b3 / x-b3-* family) so vLLM/SGLang/TGI continue the same trace. An untraced request adds nothing to the upstream wire — this is the caller’s own context, not a rolter fingerprint, so it preserves wire transparency.
  • Writes are async and batched off the hot path so logging never adds request latency.
  • The dashboard queries ClickHouse for usage, spend, latency percentiles and error rates, sliced by org/team/project/key/model.
  • Who reads it is decided per row: every analytics and health query binds the caller’s tenancy, and captured bodies are masked below the request_payload floor (member, or viewer on a project that allows it). See Who reads the request log.

Time bounds on the read API

Every control-plane read over ClickHouse (the five /api/v1/analytics/* endpoints, the three /api/v1/health/* rollups, /api/v1/mcp/logs and its summary, and /api/v1/me/usage) takes a caller-supplied since/until, and the two keyset-paged lists also take a cursor whose first half is a timestamp. All of them are bound as ClickHouse parameters and parsed in SQL with parseDateTime64BestEffortOrZero. The OrZero variant is required: ClickHouse constant-folds both branches of the if that picks the default window, so a strict parse of an absent (empty) bound aborts the query (#1177). Its side effect is that anything the parser cannot read becomes 1970-01-01, so before #1192 a typo in since silently scanned the whole table.

crates/rolter-control/src/time_bounds.rs closes that gap before any SQL is built:

  • is_time_bound accepts one grammar: YYYY-MM-DD, optionally followed by T or a space and hh:mm[:ss[.f{1,9}]], optionally followed by Z or ±hh:mm. That is RFC 3339 as the dashboard writes it (toISOString()), the YYYY-MM-DD hh:mm:ss.sss form ClickHouse returns for a DateTime64(3) under the default date_time_output_format=simple (so every cursor the API hands out), and a bare date.
  • It is narrower than RFC 3339 exactly where ClickHouse misreads it. Measured against ClickHouse 24.10, each of these parses to the epoch: a lowercase t or z, year 0000, a day the calendar lacks (2026-02-30), and an offset whose + a client left unencoded, which the query string decodes to a space. Years outside DateTime64’s 1900-2299 range are accepted because ClickHouse clamps them to that range instead.
  • The analytics, health, MCP-log and self-service modules import time_bounds::Query in place of axum’s Query. It deserializes the same way, then checks since and until through the TimeBounds trait every windowed query type implements, so a new query type that never names its window does not compile. An empty bound still means “the default”.
  • analytics::parse_keyset_cursor applies the same grammar to the cursor’s timestamp half, for both the invocation list and the MCP call log.
  • A refusal is a 400 in the gateway’s OpenAI-style envelope, {"error": {"message", "type": "invalid_request_error", "param", "code"}}, with code invalid_time_bound, invalid_cursor, or invalid_query for a query string that does not deserialize at all (axum’s own rejection is plain text). When the value is a valid bound with its + turned into a space, the message says to send %2B.

A valid bound is forwarded byte for byte, so it means what it always meant. The router-level tests in time_bounds.rs run every windowed route against a stand-in ClickHouse to prove both halves: a malformed bound never reaches the database, and a valid one arrives unchanged. every_documented_time_bound_is_on_a_checked_route fails when the served OpenAPI document gains a route with a since, until or cursor that the table there does not list. /api/v1/me/usage authenticates a session first, so self_service_key_lifecycle in tests/control_integration.rs covers it.

Provider health events

  • Every health signal is written to ClickHouse (provider_health_events): target_id, provider, source, outcome, status_code, latency_ms, error_kind, and ts.
  • ts is the instant the observation was made, stamped by the emitter, not by the batch writer: a passive event carries the ts of the request it was derived from, and a probe or status_page event carries the moment that poll completed. As with request_logs, gateways older than #1210 left this to default now64(3) and so recorded the flush time, which collapsed a whole sweep onto one millisecond and skewed the uptime/MTTR buckets below.
  • source distinguishes where the observation came from: passive (real traffic completing through the request funnel), probe (active liveness sweeps), and the opt-in llm_call / status_page sources.
  • outcome is ok / error / timeout; error_kind gives a coarse label (rate_limited, upstream_error, connect_error, timeout).
  • Writes reuse the same async, batched, off-hot-path writer and ClickHouse endpoint as request_logs; when no clickhouse_url is configured the sink is a no-op.
  • Counters rolter_health_events_written_total and rolter_health_events_dropped_total track the writer, mirroring the request-log counters.
  • This event stream feeds uptime %/MTTR rollups and the dashboard health panel.

Passive events are per attempt, not per request

A passive event describes one upstream attempt, not one client request. A request that fails over makes several, and each one is an independent observation of the target it went to.

This used to be per request (#1646). The whole passive funnel — the health event, rolter_target_requests_total{provider,target,outcome} and rolter_upstream_errors_total — was derived from the request log row, whose provider/target are those of the attempt that finally answered the caller. So when a target 503’d a quarter of its requests and the gateway failed over, the record read like this:

surfacewhat it saidwhat was true
GET /api/v1/health/uptime (target grain)ok=23 errors=0 uptime=1~25% of attempts to that target failed
rolter_target_requests_total{provider="sick",…}no series at all4 failed attempts
rolter_upstream_errors_total04
GET /api/v1/analytics/summaryrequests=374 errors=0correct — the clients really did get 200s

Failover doing its job is exactly when an operator most needs to see that a target is sick, so a sick target was indistinguishable from a healthy one on every Health screen and in every metric. Now each superseded attempt is recorded against the target that produced it as it happens, and the same fleet reads ok=23 errors=4 on the target grain with a matching error series in Prometheus — “served 100%, but 25% of attempts to this target failed”.

The request-level row still describes the attempt that answered the caller, and the two never double-count: an attempt the failover funnel already recorded is logged with the request-level target attribution suppressed. request_logs and /api/v1/analytics/summary are unchanged — they are per request by definition, and the client really did get a 200.

Stability rollup API

Read-only, window-bounded rollups over provider_health_events, served by the control plane when --clickhouse-url is set (otherwise 503). All accept since/until (default last 7 days), checked as described in time bounds on the read API and then passed as ClickHouse query parameters, never interpolated.

  • GET /api/v1/health/uptime — per provider/target: event counts, uptime, failure_rate, error_budget_burn and sla_breached against an sla target (query param, fraction in (0,1], default 0.99), and last_event.
  • GET /api/v1/health/mttr — per provider/target mean time to recovery (mttr_seconds) and incident count, computed from downtime episodes (a run of non-ok events bounded by ok).
  • GET /api/v1/health/timeline?bucket=hour|day|week|month — bucketed ok/error/timeout counts per provider/target for the failure timeline (default bucket hour).

Two grains, and why they are never summed

The three rollups group by (provider, target_id), and target_id means two different things depending on the source that wrote the row. A probe or status_page event watches the provider as a whole and carries the provider’s own name as its target_id; a passive event is derived from one completed request and carries that request’s real target. Both grains therefore land in the same rollup, describing the same provider, with numbers that are not comparable — a probe fires every probe_interval_secs regardless of traffic, while a passive observation only exists because a request happened.

Every row consequently reports a grain: provider when target_id is the provider itself, target when it is one route through it. uptime also returns sources, the sorted distinct source values behind the row.

Reading the numbers:

  • A provider row answers “is this provider reachable at all, right now?”. Its instant is the last probe or status-page poll, and its denominator is the number of polls in the window.
  • A target row answers “what did real traffic through this route see?”. Its instant is the last request that used it, and its denominator is the number of requests in the window.

The two are never added together. The dashboard renders one card per provider, headlined by the provider row when there is one, with the target rows nested inside it; a provider with no probes configured has no provider row, so the card sums its target rows — which is sound, because every request went through exactly one of them — and labels the headline as a roll-up. Before #1257 the screen laid every row out as a peer card, so a single dead provider appeared several times over with contradictory failure counts.

SLA states on the dashboard

The dashboard screen is labelled Provider Health. Its nav key is still circuit-breaker, so old links keep working, but it never reads the gateway’s circuit breakers: those live in gateway memory and leave it only as /metrics counters. Until #2113 the screen called sla_breached tripped, which read as live breaker state when it was a 7-day SLA verdict. Each card and target row carries one of three SLA states instead:

  • breaching — sla_breached from the uptime rollup: the window’s failure rate is over 1 - sla.
  • atRisk — the window meets the SLA, but the two newest hourly buckets of the timeline fail faster than it allows, i.e. error_budget_burn over those buckets alone is above 1. Over the whole window a burn above 1 is a breach, so a middle state has to come from a shorter span.
  • within — neither.

“Newest” is measured from the latest bucket in the timeline response, not from the browser clock: ClickHouse writes buckets as zone-less 2026-08-06 10:00:00 strings in its own zone, which the browser cannot know. A probed provider’s card takes the state of its provider row and buckets; a rolled-up card takes the worst state of its targets, the same rule its breach flag already followed.

Health

  • GET /healthz on both binaries for liveness probes.
  • GET /readyz on the gateway for readiness. It returns 503 draining once the control plane marks the node as draining (PUT /api/v1/cluster/nodes/{id}/drain), so a load balancer stops sending new traffic while in-flight requests finish; /healthz stays 200 because the process is healthy. The drain reaches the node on the snapshot poll it already makes, and the control plane refuses to drain the last live gateway.

Client disconnects

A caller can leave at any point in a request: a browser tab closes mid-stream, a client-side timeout fires while the upstream is still thinking, a Kubernetes rollout kills the pod holding the connection. The gateway sees this as the response future — or the response body stream — being dropped, and it happens often enough at scale that “undefined” is not an acceptable answer. This page states what rolter does (#1083).

The three guarantees

  1. The request is logged exactly once, with status 499. The status is nginx’s convention for client closed request: it is not a real HTTP status, it never reaches a caller (there is nobody left to receive it), and it exists so an abandoned request is distinguishable in analytics from both a success and an upstream failure. The row carries error = "client disconnected".
  2. No retry is attempted. A disconnect is the caller’s decision, not an upstream fault. Failing over to a second provider would double the cost of every abandoned request and add load precisely when callers are timing out.
  3. The in-flight slot is released. The balancer routes on live in-flight counts, so a leaked slot permanently skews every later routing decision for that model — a silent, restart-only degradation.

Tokens the provider already produced stay on the row. An abandoned stream that received a usage frame is real spend; dropping it would under-bill.

The row names the target the caller left (#1816). The guard is armed before a target is picked, so each attempt of the forward loop calls CancelGuard::attribute(provider, target, variant) as it picks one, and a retry that fails over moves the row with it. The classic loop, forward_variants and the multipart loop all do. A request abandoned before any pick keeps an empty provider, which is how that rarer case stays distinguishable. Without the attribution, the slow target that made callers give up was exactly the one missing from the logs: a dogfood run logged 17 timed-out requests as provider = ''.

A named row is charged to its target through LogSink::log, like any failure: rolter_target_requests_total{outcome="error"} and a passive health event. Whether a disconnect should be charged at all is open in #1933. What is settled is that one upstream failure is charged once. The forward loops funnel a failed attempt that a retry will supersede through CancelGuard::record_failed_attempt (#1646), which also marks the guard, and then back off. A caller who leaves during that backoff still gets a row naming the target, but the guard’s Drop logs it through LogSink::log_recorded_attempt, which skips the per-target counter and the health event, the same way the loop’s own error row does for an attempt it already funnelled. attribute clears the mark, since the next attempt has not been counted anywhere yet.

How it is enforced

WhereMechanism
Before the response startsCancelGuard (crates/rolter-gateway/src/cancel.rs) is armed before the forward loop in proxy() and proxy_multipart(). Its Drop stamps 499, the error text and the elapsed latency, counts the disconnect and logs the row. It is disarmed the moment an arm takes ownership of the row, so a normal answer never trips it
While the body streamsUsageLoggingStream (logging.rs) tracks whether it reached Poll::Ready(None). A stream dropped before that finalizes its row as a disconnect, keeping whatever token counts the frames already carried
In-flight accountingLoadGuard’s Drop returns the slot; it is held by the same future, so cancellation releases it on the same unwind

Both paths are RAII rather than explicit cleanup on purpose: a cancelled future is never resumed, so anything written as “log after the await” would simply not run.

Metrics

SeriesMeaning
rolter_client_disconnects_totalcounter of requests whose caller left before the response completed. A rising rate usually means client timeouts are tighter than upstream latency, not that anything is broken here
rolter_inflight_requestsgauge of requests currently in flight, summed across targets and sampled at scrape time. On an idle gateway it must read 0 — a floor that never returns to zero is a leaked slot, and the fastest signal that guarantee 3 has regressed

crates/rolter-gateway/tests/client_disconnect.rs pins all of it: a non-streaming caller that times out, a caller that leaves during a retry backoff (one error on the target and one health event, not two), a stream abandoned mid-body, and a normal request that must not be marked or counted.

Billed but withheld

A post-call policy decides what the caller receives. It does not decide what the provider charged. By the time an output guardrail or a post-response plugin runs, the upstream has already generated the answer and billed for it, so a refusal at that point is a delivery outcome, not a billing one. This page states how rolter accounts for that case (#1478).

Before #1478 a blocked response returned its 403 before the accounting stream existed. Spend never reached the budget counters, tokens never reached the rate-limit windows, and no row was written. A caller could spend real money, repeatedly, while every budget and dashboard read zero.

The guarantees

  1. Usage is read before any policy runs. On the buffered path (a translation, output guardrails, post-response plugins or the PII sanitizer’s response leg), the gateway parses usage from the upstream body right after dialect translation and before any policy touches it. That value is what gets billed. A guardrail mask, a plugin rewrite that drops or zeroes the usage object, or a sanitizer re-serialization changes what the caller sees and nothing else.
  2. A refusal is billed exactly once. When a guardrail or plugin blocks, the refusal goes through the same UsageLoggingStream::finalize as a delivered response, which enqueues spend and token records and writes the row. Nothing else writes that request’s spend. A retried request is billed only for the attempt that answered, because superseded attempts produced no usage and write no row of their own.
  3. The row keeps both outcomes. status is what the caller got (403), withheld = 1 marks the divergence, error names the policy (guardrail_blocked: <rule> or plugin_blocked: <reason>), and the token and cost columns hold the provider’s figures.
  4. The rejected content is never recorded. The refused body is never handed to the accounting stream, so it can’t reach request_payloads even with payload capture on. The request payload is still captured as usual.
  5. Cache hits stay free. A withheld response-cache hit writes a row with cache_hit = 1, withheld = 1 and zero tokens and cost. Nothing was generated upstream for it. The miss that populated the entry was billed when it happened.
  6. Unknown usage is not zero usage. usage_unknown = 1 marks a row whose upstream answered successfully without reporting any usage, so its zeros are unknown rather than free. This covers every row, not only withheld ones. The usual cause is an OpenAI-style stream without stream_options.include_usage.
  7. A body that never arrives is still logged (#1775). Both buffered paths (the policy branch of stream_response and the cacheable miss in proxy) read the whole upstream body before answering. When that read fails after a 2xx status line, body_read_failed in handlers.rs writes one row: 502, the read error, usage_unknown = 1. It goes through LogSink::log, so the target’s passive health funnel and rolter_target_requests_total{outcome="error"} count it, and rolter_upstream_errors_total moves. It used to return a bare 502 with no row, as if the request had never happened. Covered by withheld_usage.rs against an upstream that closes a 200 mid-body.

rolter_withheld_responses_total counts refusals of both kinds, cache hits included.

How it is enforced

WhereMechanism
handlers::stream_response, buffered branchparse_usage right after translate_response; a guardrail or plugin block returns through withheld_response
handlers::proxy, cacheable misssame, before the output guard; the entry is still stored as the upstream sent it
handlers::buffered_responsetakes the pre-policy usage and hands it to the stream through with_billed_usage, so a delivered-but-rewritten body is billed from the provider’s figures
handlers::withheld_responsebuilds a UsageLoggingStream over an empty body with the billed usage, then calls withhold(status, reason), which marks the row and drops the stream (one finalize)
handlers::cached_responsea blocked hit logs a zero-cost withheld row
logging::UsageLoggingStream::finalizeprefers the billed usage over the buffer and sets usage_unknown from Usage::reported
clickhouse/011_withheld_usage.sqladds withheld and usage_unknown, both UInt8 default 0

A live SSE stream never reaches a post-call block. Output guardrails either refuse a streamed request before any upstream call (streaming_post_call = "reject", the default) or let it through unmasked ("passthrough"), and post-response plugins are skipped on streams. A stream buffered for the response cache is the exception, and it takes the cacheable-miss path above with its SSE usage frames parsed.

Tests

crates/rolter-gateway/tests/withheld_usage.rs drives the gateway over HTTP against mock upstreams and a stand-in ClickHouse. It covers redacted versus blocked output, a blocking plugin, a plugin that rewrites the usage object, an upstream that reports no usage, and a retried request. The budget-counter and response-cache cases need Redis. They read ROLTER_TEST_REDIS_URL, skip when it is unset, and isolate themselves by a unique org id and cache namespace. The nextest job in quality.yml runs a Redis service for them. To run them locally:

docker run -d --rm --name rolter-test-redis -p 127.0.0.1:26379:6379 redis:8-alpine
ROLTER_TEST_REDIS_URL=redis://127.0.0.1:26379/ \
  cargo test -p rolter-gateway --test withheld_usage
docker rm -f rolter-test-redis

Redis connections on the data plane

Three gateway components keep state in Redis: the budget enforcer (crates/rolter-gateway/src/budgets.rs), the rate limiter (rate_limits.rs) and the response cache (cache.rs). Each holds one multiplexed connection through ReconnectingRedis in crates/rolter-gateway/src/redis_conn.rs. This page covers what happens to that connection when Redis goes away and comes back.

The contract

  • During an outage, fail open. Redis unreachable means requests pass unchecked, spend and tokens go unrecorded, and every cache lookup misses. A counter-store outage never takes the data plane down.
  • After an outage, recover without a restart. Once Redis is reachable again, the enforcers and the cache that already exist resume on their own. A gateway restart or a snapshot update is not needed. Counters that Redis kept (it persists them, or it never went down and only dropped connections) are enforced again straight away.

Before #1483 only the first half held. The connection sat in a OnceCell, so the first connection was the only one the process would ever make. After Redis closed it (a restart, a failover, CLIENT KILL, an idle timeout on a proxy in between), every later command failed against the dead handle. Budgets and rate limits then stayed failed open, and recording stayed off, until the gateway restarted. That was true even though Redis itself was healthy again.

How a lost connection is replaced

The live connection sits in an ArcSwapOption. On the connected path, taking it is a lock-free load plus a clone of the MultiplexedConnection handle, which is the same cost the OnceCell had.

Commands go through a Lease, which implements redis::aio::ConnectionLike, so call sites pass it to query_async and the AsyncCommands methods as they did before. When a command fails with a connection-level error, the lease evicts its connection from the slot. Connection-level errors are:

  • I/O errors, a broken pipe, or a reset
  • a response timeout
  • an error the client marks unrecoverable
  • READONLY from a primary that a failover demoted

A server rejecting one command, such as WRONGTYPE, leaves the connection in place. Each connection carries a generation number, so a failure reported late against an old connection can never evict its replacement.

The next caller that finds the slot empty reconnects straight away. Losing a connection that worked does not count as a failed attempt, so no backoff applies to it.

Which commands are replayed

A command that fails because its connection died is replayed on a fresh connection only if it is read-only. The allowlist is GET, MGET, LRANGE, EXISTS, TTL, PTTL and PING. A pipeline is replayed only when every command in it is on that list.

The budget and cache admission reads are on that list: the budget MGET and the cache GET/LRANGE/MGET. So the first request after a drop is still checked against the counters, rather than failed open.

Writes are not replayed by default. The client library reports “never sent” and “sent, reply lost” as the same BrokenPipe, and replaying an INCRBYFLOAT in the second case would charge one request’s spend twice. A write that meets a dead connection is lost, the same way any write is lost during an outage. The write after it uses the new connection. In practice one request’s spend or token count per consumer can go unrecorded at the moment a connection drops.

A caller can opt a lease into replaying writes with Lease::replay_writes, but only after it has worked out what a double application costs. Rate-limit admission is the one caller that does. It is a single atomic script that both checks and charges, and replaying it can at worst over-count one request in a bucket that expires within two minutes. That is stricter, never looser. The trade-off is argued in Rate limiting.

The redis crate can report a closed socket before the next command is sent, through a synthesized Disconnection push. Rolter does not use it, because the push is only delivered on RESP3 connections. Rolter uses whatever protocol the url asks for (RESP2 by default), so it keeps working behind proxies and servers that do not speak HELLO 3.

Bounded reconnects during a real outage

Connection attempts are single-flight. Callers that arrive while an attempt is in progress wait on an async mutex for that attempt instead of opening their own. The mutex is only taken when there is no live connection, so the connected path never touches it.

Consecutive failed attempts back off exponentially: 100 ms after the first, doubling each time, up to a ceiling of 5 s. While a backoff window is open, no attempt is made at all. Callers get “unavailable” at once and fail open without waiting. Each consumer therefore makes at most one connection attempt per backoff window during an outage, however much traffic the gateway is serving, and a replica makes three in total (budgets, rate limits, cache).

Two timeouts bound how long a caller can wait:

TimeoutValueWhat it bounds
connect timeout1 sthe callers that wait on one connection attempt, including one against a blackholed address
response timeout500 msone command; a half-open socket fails at this point and its connection is evicted, instead of stalling every request that uses it

These match the redis crate’s own defaults. ReconnectPolicy sets them explicitly so that a dependency bump cannot change them without anyone noticing.

What operators see

  • On the first failure: redis connection lost; reconnecting on next use (warn), with the cause.
  • During an outage: redis unavailable; failing open until it is reachable again (warn), with retry_in_ms. This is logged once per attempt, so at most once per backoff window for each consumer, never once per request.
  • On recovery: redis connection re-established; enforcement resumed (info).

Each line carries consumer = budgets, rate_limits or response_cache, the same value as the metrics’ consumer label.

On /metrics and over OTLP (#1772), each consumer that has a Redis URL reports:

  • rolter_redis_connected{consumer}: 1 while it holds a live connection, 0 once an attempt failed or the connection was evicted;
  • rolter_redis_reconnects_total{consumer} and rolter_redis_connect_failures_total{consumer};
  • rolter_fail_open_total{control="budget"|"rate_limit"}: admission checks that let a request through because Redis could not be reached, counted where exceeded and check_at give up (no connection, or the read or admission script failed). The response cache backs no control, so it has no series.

ReconnectingRedis holds these as RedisConnStats (plain atomics in metrics.rs) and updates them only on the slow path: in connect and invalidate, and in the consumers’ fail-open branches. A command over a live connection touches none of them. AppState::with_logging registers each consumer and warms its connection in the background (warm_up), so the gauge is truthful from startup rather than reading 0 until the first budgeted request. After that it is what the last use found: an idle consumer does not probe, which the user docs spell out next to the metric.

Tests

The contract is tested against a real Redis. The tests self-skip unless ROLTER_TEST_REDIS_URL is set; see Testing.

  • budgets, rate_limits and cache each have a test that closes the component’s own connection with CLIENT KILL. The test then asserts that the same instance enforces against the counters that survived the kill, and that it records through the new connection.
  • redis_conn puts a TCP forwarder in front of Redis and takes it down and back up. Those tests cover an outage followed by a restart, the fact that reads are replayed and writes are not, and single-flight connection attempts.
  • Tests that point at a closed port cover backoff pacing and the fail-open latency.
  • The outage-and-restart test also reads the three connection series back through Metrics::render: connected before, disconnected with failures counted during, reconnected once after. The fail-open window test asserts one counted failure for a burst of 500 callers.

Rate limiting

[[rate_limits]] caps a scope (org, team, project, key, business unit or customer) to rpm requests and/or tpm tokens per minute. The gateway enforces the caps in crates/rolter-gateway/src/rate_limits.rs, using counters in Redis that every replica shares. This page covers two separate things:

  • the approximation: how the counters estimate “the last 60 seconds”
  • the concurrency guarantee: why concurrent requests can’t all take the same free slot

They are independent. The estimate can be off at the edges of a bucket while admission is still exact under concurrency.

The window: a sliding-window counter

Each limit keeps one counter per fixed one-minute bucket (unix_seconds / 60) and per kind:

rolter:rl:<scope>:<id>:<req|tok>:<bucket>

A check reads the current bucket and the previous one, and weights the previous one by how much of it still lies inside the trailing 60 seconds:

estimate = current + previous × (60 − seconds_into_current_bucket) / 60

On a bucket boundary the previous bucket counts in full. Half way through it counts for half. This is an approximation: it assumes the previous bucket’s requests were spread evenly across its minute.

  • Traffic bunched at the end of the previous minute is under-weighted. For example, suppose 60 requests arrived in its last second. Thirty seconds into the next minute they count as 30, although all 60 are still inside the trailing window.
  • Traffic bunched at the start is over-weighted by the same logic.

The error is bounded by one bucket’s traffic and disappears as traffic evens out. In exchange, a check costs two reads per limit and no per-request data. The smoothing is still better than a plain fixed window, which would allow 2 × rpm across a bucket edge.

Retry-After on a 429 is the number of seconds left in the current bucket. That is when the weighting next changes in the caller’s favour.

Buckets expire two minutes after their last write, so idle limits clean themselves up.

Admission is one atomic step

A request is admitted only if every applicable limit passes:

  • estimate + 1 ≤ rpm
  • estimate < tpm (explained below)

The limits are checked in scope-chain order and the first refusal wins (most-restrictive-wins). An admitted request is charged to every applicable rpm bucket. A refused one is charged to none, so a request that the key’s limit rejects costs the org nothing.

The whole decision runs as one Lua script (ADMIT in rate_limits.rs) on the Redis server. The script reads the buckets, evaluates every limit and increments the request buckets. Redis runs a script to completion before it serves any other command, so no request can read a counter between another request’s read and its increment. Every replica talks to the same Redis, so this holds across replicas too.

Before #1484 the gateway ran the same logic as three client-side steps: MGET, decide, then an INCR pipeline. Requests arriving together all read the same counts before any of them wrote, so 32 concurrent requests against rpm = 1 were all admitted. Multiplexing and extra replicas made that more likely, not less.

The script is loaded once per server with EVALSHA. After a Redis restart the script cache is empty, so the first call gets NOSCRIPT, loads the script and retries. The redis crate’s Script::invoke_async handles that.

The script touches keys belonging to several scopes, so it needs a single Redis node (or a primary with replicas). It cannot run on Redis Cluster, where those keys would hash to different slots. The gateway connects with a single-node client in any case.

Tokens are reactive

Token usage is only known once the response has finished. So tpm is read in the same atomic step (the decision sees one consistent snapshot), but it is only charged afterwards, by record_tokens.

A request is refused once the trailing window has already reached the cap. Requests admitted together before that point can overshoot the cap by their own usage. This is the documented reactive behaviour of tpm, not a race: the tokens do not exist yet at admission time, so no atomic step can charge them. Reserving an estimate up front is a separate feature (#1464).

Realtime sessions

A /v1/realtime upgrade goes through the same admission step, keyed on the opening key’s scope chain, so a session costs one request against rpm and is refused while tpm is full. The process-local [realtime] max_connections cap is checked just before it, so a session the gateway would have refused anyway never takes a slot.

A session’s tokens are charged the way a stream’s are, only later: each finished response turn is added to tpm on the session meter’s next flush. A full window does not close a live session, for the same reason it does not cut off an HTTP response in flight. See Realtime metering.

When the Redis connection drops

Admission is the one write that opts out of the no-replay rule in Redis connections. If the script call meets a dead connection, it is replayed once on a fresh one, so the first request after a drop is still enforced.

The replay is exact when the first attempt never reached Redis, which is the usual case: the connection was already dead. If the script did run and only its reply was lost, the replay charges one extra request to a bucket that expires within two minutes, and may refuse a request that the lost reply had admitted. That errs by one towards stricter, for one window. The alternative is to admit the request unchecked and uncounted, and for a throughput control that is the worse failure.

Spend recording stays on the no-replay side. A double-applied INCRBYFLOAT would corrupt a budget for its whole period.

While Redis is unreachable, admission fails open, like every other Redis-backed control.

Tests

Tests against a real Redis live in rate_limits.rs. They self-skip without ROLTER_TEST_REDIS_URL; see Testing.

  • 64 concurrent requests against rpm = 1 admit exactly one.
  • Three limiters with separate connections, standing in for three replicas, race 96 requests against rpm = 5. Exactly 5 are admitted and exactly 5 are counted.
  • The org/key scope chain under concurrency: the key’s cap wins, and the org is charged only for what the key admitted.
  • Window boundaries, with the clock pinned through check_at: the previous bucket counts in full on the boundary and for half at mid-minute.
  • A full tpm window refuses and charges no request slot.
  • Recovery after CLIENT KILL: the first check after the drop is enforced through the replayed script.

Realtime metering

A /v1/realtime session is a WebSocket that can stay open for an hour and run hundreds of model responses. Until #1396 it was admitted against the process-local [realtime] caps and nothing else: no budget, no rate limit, no request-log row. This page states how a session is admitted, how it is metered and what happens when its budget runs out.

The code is in crates/rolter-gateway/src/realtime.rs (admission and the relay loop) and crates/rolter-gateway/src/realtime_metering.rs (the meter).

Admission

The upgrade request goes through the same checks as an HTTP request, keyed on the same scope chain (key, project, team, org, plus the business unit and customer the key is attributed to). They run before any upstream is dialled:

OrderCheckRefusal
1authentication, model, route401 / 403 / 404, unchanged
2budgets402 insufficient_quota, the HTTP path’s budget_refusal
3unpriced_policy402 model_unpriced when the policy resolves to block
4the gateway is shutting down503, see Shutdown
5[realtime] max_connections429, the process-local resource guard
6rate limits429 rate_limit_exceeded with Retry-After
7upstream connect with failover502 when every candidate target fails

Opening a session is one request against rpm. The process-local cap is checked first so that a session this gateway could not hold anyway never costs the key a slot. The tokens a session’s turns use reach tpm as they are metered, so a key that spent its token window over a socket is refused its next session, or its next HTTP request, like any other usage.

A full tpm window does not close a live session. Rate limits refuse new work, and the HTTP path does not cut off a response in flight either.

The metering unit: the response turn

A session is metered in the unit the upstream bills and reports. Every response.done server event carries the usage of the response it ends, with input_tokens, output_tokens, total_tokens and input_token_details.cached_tokens, and that is what gets priced against the route model’s [[model_prices]] row.

The alternatives were weighed and rejected:

  • Metering on close leaves a session that never closes cleanly unaccounted until it is killed, and a session that ends because the network dropped may never be accounted at all.
  • Metering wall-clock time bills something no provider charges for, and cannot be reconciled with a provider invoice.
  • Estimating from audio bytes duplicates the provider’s own tokenizer badly, while the provider reports the exact count on every turn.

Reading the event stream cheaply

The relay hands every upstream text frame to a TurnTracker. Almost every frame is a delta carrying base64 audio, often tens of kilobytes, so parsing each one would put a JSON decode of the whole audio stream on the relay’s path. The tracker reads the event type from the leading bytes of the object instead (the first key, or the second behind event_id, which are the two places the Realtime API writes it) and fully parses only response.created and response.done, which are small. A frame whose type sits anywhere else falls back to a substring scan: slower, but still metered correctly.

response.created opens a turn and starts its clock, and response.done closes it. Time to first token is stamped by the first response.*.delta that names the turn in its response_id, or by the first one naming none. Other deltas do not count: conversation.item.input_audio_transcription.delta streams the user’s own words, often well before the model answers. A done whose created was never seen is still billed, with zero latency.

The periodic flush

A closed turn goes to the session’s meter task over a bounded channel. The relay never waits on Redis or ClickHouse; it carries live audio.

Every [realtime] usage_flush_secs (default 15), and once more when the session ends, the meter flushes:

  1. one request_logs row per turn, attributed like an HTTP row: org, team, project, key, business unit, customer, model, provider, target and variant. Its request_id is the upgrade request’s id with the turn’s ordinal appended (<id>:1, <id>:2, …), so every row is unique and one session’s rows share a prefix and a trace_id
  2. the window’s cost added to every applicable budget counter, in one write
  3. the window’s tokens added to every applicable tpm window
  4. a re-read of the session’s budgets

Rows are per turn rather than per window so that latency, time to first token and token counts in analytics describe one model response, as they do for HTTP. The flush window only batches the counter writes and bounds how late enforcement can be.

usage_flush_secs = 0 flushes after every turn instead of on a timer. The meter still ticks once a second in that mode, but only to re-read the budgets while the session is quiet (see the next section), so 0 is the tightest setting on both counts. There is no setting that turns metering off.

The snapshot is read per flush, so a price, budget or limit edited mid-session applies from the next flush on.

A budget that runs out mid-session

The meter re-reads the budgets on every tick, not only after this session spent something: a budget is shared by its whole scope chain, so another session or plain HTTP traffic may be what used it up. A tick is one usage_flush_secs, or one second when that is 0. The re-read costs nothing when no budget applies to the session or Redis is not configured.

When a budget is spent the session is closed. The client first receives a Realtime error event whose error object is the HTTP refusal’s:

{
  "type": "error",
  "event_id": "event_rolter_…",
  "error": {
    "type": "insufficient_quota",
    "code": "insufficient_quota",
    "message": "budget exceeded for Org 'acme' (limit $500.00)",
    "param": null
  }
}

It then receives a close frame with code 1008 (policy violation), and the upstream leg is closed. rolter_budget_blocks_total counts the close.

Closing is the conservative choice of the three the issue named:

  • Degrading has nothing to degrade to. The session is pinned to one upstream and model for its life, and switching mid-session is exactly the failover the relay refuses to do because replaying audio or tool events is unsafe.
  • Allowing the session to completion turns a budget into a suggestion for an hour at a time, which is the bug #1396 was filed about.

The overshoot is bounded: at most the spend of one flush window, plus whatever turn was in flight when the budget ran out. An operator who needs a tighter bound lowers usage_flush_secs.

Responses cut short

A response still in flight when the session ends never reports usage, but the upstream generated part of it and will bill it. The last flush writes a row for it with usage_unknown = 1, zero tokens, and the reason the session ended as its status:

Session ended becausestatuserror
the client closed or went away499client disconnected
the upstream closed the session502upstream closed the realtime session
the upstream connection failed502upstream realtime connection failed
max_session_secs or idle time408realtime session closed by <limit>
a budget ran out402the budget refusal message
the gateway shut down503gateway shutting down

This follows Client disconnects and Billed but withheld: spend that cannot be counted is marked as unknown, never recorded as free. A row cut short by the client also counts on rolter_client_disconnects_total.

These rows do not count against the target’s health. A client leaving, a session limit or a spent budget is not the upstream’s failure, and when the upstream leg did break, the relay has already attributed that to the target once for the whole session.

A turn whose response.done says status: "failed" is logged with status 502. Its usage is billed if the upstream reported any.

Shutdown

A session outlives the HTTP request that opened it. axum’s graceful shutdown stops tracking a connection once it upgrades to a WebSocket, so on its own it would let the process exit under live sessions. The runtime would then cancel each relay and meter wherever it stood, and every turn still waiting for the next flush would lose its row, its budget charge and its tpm tokens. With the default usage_flush_secs that is up to 15 seconds of every session, on every rollout.

So the gateway drains realtime sessions itself (Sessions in realtime.rs, AppState::drain_realtime_sessions):

  1. Every relay and meter task is tracked from admission on, including the moment between the upgrade response and the completed handshake.
  2. When SIGTERM or Ctrl-C arrives, every session is told to close at once, in parallel with axum’s drain of plain HTTP requests. Each client receives a 1001 (going away) close, the code a WebSocket server sends when it stops, so an SDK reconnects rather than treating the session as refused. The upstream leg is closed too.
  3. New upgrades are refused with 503 from that moment.
  4. Each meter runs its last flush: rows for finished turns, usage_unknown rows with status 503 for responses in flight, budget and tpm writes.
  5. Once the HTTP drain is done, the process waits up to 10 seconds for the tracked tasks to finish, then exits. The sessions were closed when the signal arrived, so the wait only covers a meter still waiting on Redis, and it fits well inside the 30 seconds an orchestrator usually allows before SIGKILL. A session still open after it is logged as a warning.

The budget and tpm writes are awaited by the meter, so they land before the process exits. The request-log rows are handed to the shared ClickHouse writer, which is not drained at shutdown yet, so rows from the last [logging] flush_ms can still be lost. That gap is not specific to realtime (#1924).

Failure modes

  • Redis down. Admission and recording fail open, as on the HTTP path: the session is admitted, turns are still logged to ClickHouse, and spend is not counted until Redis is back. See Redis connections.
  • The meter falls behind. A turn that finds the meter’s queue full is dropped and counted on rolter_usage_records_dropped_total, the policy the HTTP usage sink already has (#1051). The queue holds 1024 turns and each flush is bounded by the Redis timeouts, so reaching it needs a thousand responses inside one stalled flush.
  • The relay task dies. The meter sees its channel close, flushes what it was already handed and stops.
  • The process shuts down. Covered by Shutdown. A SIGKILL, or a drain that outlives its 10 seconds, still loses whatever the meter had not written.

What is not metered

These are tracked rather than silently missing:

  • Guardrails and plugins do not run on realtime events (#1880). This is why realtime still carries its stability marker: the marker’s note named guardrails alongside metering, and a subsystem graduates in the pull request that closes the last gap its note names.
  • A revoked or expired key does not end a live session (#1881).
  • Audio and text tokens are priced at the same rate, because a price row has one input and one output rate (#1882).
  • Input-audio transcription runs a second model whose usage arrives on conversation.item.input_audio_transcription.completed, which the meter does not read yet (#1883).

Tests

crates/rolter-gateway/tests/realtime_metering.rs drives real sessions against a mock Realtime upstream: a completed turn is logged with its usage and cost, a session that stays open is accounted on the flush timer, a response cut short is logged as unknown, turns waiting on the timer are flushed when the session ends, an unpriced model is refused under block, a spent budget refuses a new session, a session is closed when its budget runs out, a budget spent by other traffic closes an idle session on the flush timer and, with usage_flush_secs = 0, on the one-second budget tick, usage is charged while the session continues, rpm applies per key rather than per process, and turn tokens fill the key’s tpm window. The Redis-backed ones read ROLTER_TEST_REDIS_URL and skip, or skip their Redis assertions, without it.

Shutdown has three: an in-process drain flushes every live session before it returns and refuses new ones with 503, a response in flight at shutdown is logged as unknown, and a real rolter-gateway child sent SIGTERM closes its session with 1001 and exits 0 only after the meter has flushed. With Redis, the first and the third route the gateway through a relay that holds every reply back by 200ms once shutdown starts. A flush charges a budget with INCRBYFLOAT and sends its EXPIRE only after that reply, so a budget key that carries its expiry proves the meter ran to the end of its flush instead of merely starting it. Sessions itself is unit-tested for holding the drain while a meter or an upgrade is still running.

The tracker’s frame handling, including which deltas count as a first token, is unit-tested in realtime_metering.rs itself.

Performance

Goal: beat the reference Python proxy (LiteLLM cites ~8ms P95 added latency at 1k RPS) with a much smaller per-request overhead in Rust.

Hot-path principles

  • Lock-free config reads — the routing table is an ArcSwap<Snapshot>; readers never block, even during a hot reload.
  • Minimal-copy streaming — upstream responses are piped to the client as a Body::from_stream over the reqwest byte stream; rolter does not buffer whole responses.
  • Connection reuse — pooled reqwest clients with HTTP/2 keep-alive and tcp_nodelay; one client per egress-proxy target, cached.
  • Cheap auth — virtual-key lookup is an O(1) hash-map hit on the in-memory snapshot.
  • Avoid full deserialization — only the fields needed for routing (model, stream) are read; the body is forwarded as raw bytes.
  • Logging off the hot path — usage/cost rows are batched and written to ClickHouse asynchronously.
  • Release profile — lto = "thin", codegen-units = 1, strip = true.

Things to watch

  • The approximate cache-aware trie is per-route in-memory state; bound its size with eviction before it grows large.
  • Per-request JSON parse for model is small but measurable; consider a fast path / partial parse for very high RPS.
  • Prefer bytes::Bytes (ref-counted) over Vec<u8> copies when rewriting the model field.

Benchmarking

Three layers. Only the third is on the per-PR gate:

  • Micro-benches (just bench) — criterion, covering balancer pick across every strategy and the prefix trie. Compiled on every PR via clippy so they cannot bit-rot, but not run (timings are noisy on shared runners).
  • End-to-end load (just bench-sim / bench-vllm / bench-sglang) — integration/engines/bench.py against a real engine, measuring rolter’s added latency directly and its behaviour under sustained concurrency.
  • Allocation counting (cargo test -p rolter-gateway --test hot_path_allocations) — asserts that the per-attempt admission checks make zero allocations. This one is a merge gate, because it measures a property rather than a duration.

Why allocation counting is a test and not a benchmark

“Keep the hot path allocation-light” is a rule this document states and AGENTS.md repeats, but timings alone cannot enforce it: a per-request allocation costs tens of nanoseconds, which is well inside the noise of a shared CI runner. That is why the criterion benches are compiled and not run — and it means a regression like the (String, usize) key that Breaker::allows and Cooldowns::is_parked once built could come back without anything failing.

crates/rolter-gateway/tests/hot_path_allocations.rs installs a counting #[global_allocator] and asserts an exact zero on the steady-state paths. Zero is deliberate: an allocation budget would be a portability trap across allocators and toolchains, whereas “this path does not allocate at all” is both stable and the property actually wanted. A count is exact and reproducible in a way a duration is not, so this can gate where the benches cannot.

Two details worth knowing before extending it:

  • The counter is per-thread, not global. Cargo runs a binary’s tests on parallel threads, so a process-wide counter attributes a sibling test’s ordinary allocations to whichever measurement happens to be open, and fails it for something it never did. Arming and counting are both thread-local, and a test asserts that property directly.
  • It is verified against an injected regression, not just observed passing. A guard that cannot fail is worse than none, since it reads as evidence. Put a model.to_string() back into Breaker::allows and exactly the two breaker tests fail, with one allocation per candidate target; the cooldown tests, which touch none of the changed code, keep passing.

To extend it to another hot-path component, add a test that warms any lazily built internals first — otherwise you measure first-call setup — then wraps the steady-state operation in counting(...) and asserts zero.

Tool decision (#847, closing #455)

The load harness extends bench.py rather than adopting GuideLLM or driving load with oha/k6. Recorded here because #847 required settling it before writing code.

  • Air-gapped operation is a hard requirement for rolter. bench.py is stdlib-only — no install step, no wheels or binaries to vendor. GuideLLM brings a substantial Python dependency tree; oha/k6 are external binaries. A benchmark you cannot run on the disconnected machine you are tuning is not much of a benchmark.
  • The direct-vs-rolter delta is the entire point of this harness, and no general-purpose load generator produces it. They measure one endpoint; added_latency_p50_ms needs both driven identically in the same run under the same conditions. Adopting one of them means keeping bench.py anyway for the delta — two harnesses instead of one.
  • ITL needs SSE token boundaries. oha and k6 measure bytes and requests, not tokens, so they cannot produce inter-token latency at all. GuideLLM does understand tokens, but the two points above still stand.
  • Reproducible without credentials — the default sim engine (llm-d-inference-sim) needs no API key, so anyone can reproduce a number.

The cost is carrying ~250 lines of stdlib Python for concurrency and knee detection. That was judged cheaper than a dependency tree plus a second harness. Revisit if the harness starts needing tokenizer-aware workload shaping, which is where GuideLLM genuinely earns its footprint.

What it measures

bench.py drives the engine directly and through rolter in the same run:

MetricNotes
ttft_p50/p95_mstime to first byte
itl_p50/p95_msinter-token latency, streaming only, needs --max-tokens > 1
latency_p50/p95/p99_msend to end
requests_per_secondachieved, not offered
error_ratenon-2xx and transport failures, which is what saturation looks like
added_latency_p50_msrolter minus direct — the headline overhead number

Profiles: --concurrency holds closed-loop steady state after a warmup, and --sweep 1,2,4,8,16 walks concurrency upward — a ramp/burst profile — reporting max sustainable RPS: the highest achieved throughput whose p99 stayed inside --knee-factor (default 2×) of the lowest-concurrency baseline and which was not erroring.

That bound matters. Throughput usually keeps climbing well past the point where latency collapses, so a peak RPS with no latency condition attached describes a system nobody would actually run. The report also names the concurrency level where the knee was crossed, so a reader can see where it went rather than only that it did.

just bench-sim     # sequential: per-request added latency (fast, unchanged)
just load-sim      # concurrency sweep: max sustainable RPS, ITL, error rate
just test-bench    # harness unit tests — stdlib only, no engine or network

load-* is deliberately a separate recipe: a sweep across five concurrency levels takes minutes, and silently making the added-latency run that much slower would be a bad trade. Tune with LOAD_LEVELS, LOAD_REQUESTS, LOAD_WARMUP and LOAD_MAX_TOKENS.

Two measurement rules worth knowing when reading the JSON:

  • Failed requests are counted, never timed. A connection refused in 0.1 ms is not a fast request; letting it into the percentiles would make a saturated system look quicker than a healthy one. They appear in error_rate instead.
  • ITL keys are absent, not zero, when nothing streamed. A reported ITL of 0 would read as “instant tokens” rather than “not measured”.

Baseline

No baseline snapshot is recorded yet. It needs a run on fixed, documented hardware, and a number taken on a laptop under thermal throttling would be worse than none — an unreproducible baseline invites false regressions. Record hardware, engine version and rolter version alongside the numbers when one is taken.

Inference engines

The ROL-238 suite checks rolter against OpenAI-compatible engine servers. Output is intentionally meaningless; this validates the HTTP, OpenAI JSON, and SSE contracts rather than model quality.

The default engine, sim, is llm-d-inference-sim: a ~30MB multi-arch vLLM API simulator that needs no model downloads and boots in milliseconds, which makes the suite cheap enough to run as a regular PR check. The real CPU vLLM and SGLang profiles remain for on-demand runs; they use trl-internal-testing/tiny-random-LlamaForCausalLM only for its configuration/tokenizer and initialize random weights with --load-format dummy (with a head_dim=64 override, since the CPU attention kernels reject the model’s native head_dim=4). The CPU vLLM profile uses eager execution to avoid expensive compilation warm-up during CI smoke runs.

It runs on CPU in Docker and therefore works on GitHub-hosted runners. Each engine profile starts two independent dummy upstreams so the gateway exercises a real target pool. Run one engine locally:

just integration-sim
just integration-vllm
just integration-sglang

Each command boots routes for every balancing strategy (round-robin, random, power-of-two, consistent-hash, cache-aware, weighted, pipeline, cheapest, and fastest). It verifies /v1/models, non-streaming chat, and SSE both directly and through rolter, and explicitly confirms round-robin reaches both targets. Logs are kept in artifacts/engines/<engine>/.

Local end-to-end run

just integration-vllm and just integration-sglang start two CPU dummy-weight servers, render the gateway configuration, and run the OpenAI JSON and SSE assertions. The runner cleans up all containers and child processes on exit and preserves the combined engine log plus the gateway log under artifacts/engines/<engine>/.

For manual inspection, start the selected two-server pool and leave it running:

docker compose -f docker/docker-compose.engines.yml --profile vllm up -d
# use profile sglang for ports 30000 and 30001

Render the gateway configuration in another terminal and start rolter:

config=$(mktemp)
sed \
  -e 's/__ROLTER_PORT__/4010/g' \
  -e 's/__ENGINE_1_PORT__/8000/g' \
  -e 's/__ENGINE_2_PORT__/8001/g' \
  integration/engines/rolter-dummy.toml.in >"$config"
cargo run -p rolter-gateway -- --config "$config"

Verify non-streaming JSON and streaming SSE through the gateway:

curl -i http://127.0.0.1:4010/v1/chat/completions \
  -H 'content-type: application/json' \
  -d '{"model":"dummy-round-robin","messages":[{"role":"user","content":"Reply with one token."}],"max_tokens":1,"temperature":0}'

curl -N http://127.0.0.1:4010/v1/chat/completions \
  -H 'content-type: application/json' \
  -d '{"model":"dummy-round-robin","stream":true,"messages":[{"role":"user","content":"Reply with one token."}],"max_tokens":1,"temperature":0}'

Exercise all configured strategies; each request must return a non-empty OpenAI choices array:

for model in dummy-round-robin dummy-random dummy-power-of-two \
  dummy-consistent-hash dummy-cache-aware dummy-weighted dummy-pipeline \
  dummy-cheapest dummy-fastest; do
  curl -fsS http://127.0.0.1:4010/v1/chat/completions \
    -H 'content-type: application/json' \
    -d "{\"model\":\"$model\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}],\"max_tokens\":1}" \
    | jq -e '.choices | length > 0' >/dev/null
done

Clean up the pool and temporary gateway configuration:

docker compose -f docker/docker-compose.engines.yml --profile vllm down --volumes
rm -f "$config"

For non-gating direct-versus-gateway samples, run just bench-vllm or just bench-sglang. They record non-streaming and streaming p50/p95/p99 latency and streaming first-byte time in JSON. Results only compare runs on the same host, CPU image, engine versions, and host configuration; throughput thresholds are deliberately not merge gates.

The engine integration workflow runs the sim smoke as a regular check on pull requests that touch engine paths. A newer push to the same pull request cancels the smoke still running for the older commit; a manual dispatch is never cancelled by another run. Dispatch it manually with engine=vllm to smoke the real CPU engine (Actions tab, or gh workflow run "engine integration" -f engine=vllm). SGLang remains available through the local just integration-sglang command, but its source-built CPU image is currently too heavy for the shared CI gate. This suite is for compatibility, not a performance gate. When ROL-67 lands, add the equivalent /v1/messages assertion through the gateway.

Provider prompt caching

Rolter’s response cache and provider prompt caching solve different problems. The response cache replays a complete prior answer from Redis. Provider prompt caching lets an upstream reuse stable input context while it still generates a fresh answer.

Use the portable cache_control object only on requests routed to a direct Anthropic provider:

{
  "model": "claude-route",
  "messages": [{"role": "user", "content": "Summarize this document."}],
  "cache_control": {
    "enabled": true,
    "ttl": "5m",
    "breakpoints": ["system", "tools", "messages"]
  }
}

Rolter converts each selected breakpoint to Anthropic’s native ephemeral cache_control marker. ttl is limited to 5m or 1h; omitted breakpoints defaults to system. Existing nested provider-native controls are preserved.

bedrock and vertex providers currently use their OpenAI-compatible wire mode in Rolter. They reject this portable control with prompt_cache_unsupported instead of silently forwarding it or claiming cache use. Configure a direct Anthropic provider for the portable contract until native Bedrock/Vertex Claude transports are added.

Prompt-cache reads and writes are provider billing/usage concepts. They are distinct from the invocation log’s cache_hit, which only denotes a Rolter response-cache hit.

Architecture Decision Records

Lightweight decision log. Each entry: Status · Context · Decision · Consequences. Supersede rather than rewrite.

A standalone record opens with its title and a single metadata line — no table:

# Title of the decision

**Status:** Accepted · **Date:** 6 Aug 2026 · **Issues:** [#812](https://github.com/rolter-ai/rolter/issues/812)
**Supersedes:** ADR-0007 for exact vLLM modes
**Relates:** ADR-0022 (config-vs-DB tiering)

## Context

Status, Date and Issues are the line; Supersedes and Relates get their own line and are omitted when there is nothing to say. Nothing else belongs there — authorship and dates are what git is for, and a field whose value is either constant across every record or a — (unassigned) placeholder is noise that makes the actual decision harder to find. Records are English only.

ADR-0001 — Rust + Axum/Hyper/Tower for the data plane

Accepted. Need maximum proxy throughput with rich API semantics and SSE streaming. Chose Axum/Hyper/Tower on Tokio over Pingora/Actix for ecosystem fit and ergonomics. Consequence: idiomatic async stack; revisit Pingora only if profiling demands it.

ADR-0002 — Two-binary topology over shared crates

Accepted. Keep the hot proxy path lean and independently scalable from management. rolter-gateway (data plane) and rolter-control (management + UI host) share library crates. Consequence: clear seam; some duplicated wiring.

ADR-0003 — Vite + React + shadcn/ui SPA, Bun toolchain, served by Rust

Accepted. shadcn/ui (Radix + Tailwind) for the dashboard, built with Vite and managed with Bun; output is static assets served by rolter-control (no Node runtime in prod). Consequence: simple prod footprint; Bun used for install/dev/build.

ADR-0004 — Postgres + Redis + ClickHouse

Accepted. Postgres = source of truth (config/RBAC/keys/pricing); Redis = cache + rate-limit counters + config pub/sub; ClickHouse = high-volume request/cost logs. No SQLite. Consequence: three datastores to operate; each fits its job.

ADR-0005 — Org → Team → Project → Virtual Key tenancy

Accepted. Budgets and rate limits attach at any scope, most-restrictive-wins. Consequence: flexible multi-tenancy; enforcement must resolve a scope chain.

ADR-0006 — Local accounts + virtual keys + roles; SSO/LDAP later

Accepted. v1 ships local accounts (argon2id) and roles admin/member/viewer; OAuth2/OIDC and LDAP arrive as pluggable identity providers. Consequence: usable day one without an IdP.

ADR-0007 — Approximate cache-aware balancing behind a pluggable trait

Accepted. v1 uses an approximate per-target prefix trie (no engine coupling) behind LoadBalancer. Precise (KV-events) and lmcache-aware land later without API changes. Consequence: immediate wins; precise mode is additive.

ADR-0008 — Reload-free config: Postgres truth + Redis pub/sub + ArcSwap

Accepted. Control plane writes Postgres, bumps a version, publishes on Redis; gateways fetch and atomically swap an in-memory snapshot, reconciling by version. Consequence: instant fan-out, self-healing, lock-free reads.

ADR-0009 — Envelope encryption for provider secrets

Accepted. Upstream keys are AES-256-GCM envelope-encrypted with a master key from env/file; Vault/KMS backends later. Consequence: no plaintext secrets at rest; master key is the critical secret.

ADR-0010 — Packaging: maturin (uv) + cargo + Docker

Accepted. Ship a maturin-built PyPI wheel bundling the unified rolter launcher (uv tool install rolter), cargo install rolter, and a multi-stage Docker image. The rolter binary dispatches to gateway/control subcommands so one wheel/crate ships the whole system. Consequence: three distribution paths from a single named artifact.

ADR-0011 — API surface v1

Accepted. OpenAI /v1/chat/completions, /v1/completions, /v1/models and Anthropic /v1/messages. Embeddings, images, audio and other modalities follow. Consequence: drop-in for the two dominant client SDKs first.

ADR-0012 — Conventional Commits + CI PR-title lint

Accepted. Commit messages and PR titles follow Conventional Commits; enforced by commitlint, conventional-pre-commit, and a CI PR-title check. Consequence: consistent history, automatable changelogs/releases.

ADR-0013 — OpenTelemetry-based observability with engine propagation

Accepted. Export traces/metrics via OTLP to any compatible backend (SigNoz, Datadog, Grafana, Langfuse, …); propagate W3C trace context to vLLM/SGLang so engine spans join the same trace; federate upstream engine metrics. Consequence: vendor-neutral observability; an OTel Collector is the recommended hub.

ADR-0014 — Extensible API protocol translation

Accepted. Resolve translation by client/upstream protocol pair in rolter-proxy, including incremental SSE, while the gateway retains transport, caching and accounting ownership. Consequence: new provider dialects extend one translation boundary; non-equivalent modalities remain explicit and are never silently dropped.

ADR-0015 — OpenAI Responses API translation

Development. Add OpenAI Responses as a protocol pair for native OpenAI, Chat Completions and Anthropic Messages, while model-less lifecycle operations remain uniformly unsupported until tenant-scoped storage exists.

ADR-0016 — Routing OpenAI Responses resources through a tenant-scoped registry

Development. Pin Responses lifecycle operations to a bounded tenant-scoped process-local record, preserving the original provider credential while making unknown and cross-tenant IDs indistinguishable.

ADR-0017 — Provider/model addressing to disambiguate identical model names

Accepted. First-class provider-slug/model addressing coexists with named routes: a stable, URL-safe provider slug resolves provider-slug/model to a pinned (provider, upstream_model) target (avoiding LiteLLM’s base_url ambiguity). Pinning bypasses cross-provider fan-out but still balances within the provider. Slugs are org-scoped and collision-safe: deterministic reported migration backfill with -N de-dup, 409+suggestion on runtime create-conflict (never silent suffixing), soft-delete-preferred reclaim. Addendum: provider groups unify fleets (e.g. ten vLLM instances) under one group slug in the same namespace — vllm-cluster/qwen3 fans out across members with a chosen balancing strategy and passthrough model names, so no route-per-model. Consequence: new immutable slug column + proxy parsing + /v1/models + UI work + provider-groups entity (see follow-up issues).

ADR-0018 — Config mutated via granular CRUD, not whole-config replace

Accepted. The normalized Postgres store (ADR-0004, source of truth) is mutated only through the scoped CRUD API — providers, routes, targets, virtual keys — each write bumping the config version and hot-reloading gateways via the snapshot poll (ADR-0008). PostgresConfigStore::save is deliberately read-only: there is no whole-config “apply” endpoint, because a full replace would fight the normalized model and clobber concurrent edits. The dashboard Config page is a read-only effective-config viewer; the /gw reverse-proxy that fronts the gateway for the Playground is unrelated to config writes. Consequence: one live-edit path (CRUD) with instant fan-out; a raw whole-config editor would need a deliberate transactional diff/apply (guarding config-owned entries, preserving key hashes) and is out of scope (closed #494).

ADR-0019 — Per-provider egress proxy pools

Accepted. Rotate across a provider-local proxy pool, fail over only connection/tunnel failures, and quarantine repeatedly failing members. Authenticated URLs are resolved exclusively from whole-value environment references. Consequence: resilient egress without leaking credentials; health state remains process-local.

ADR-0020 — Bounded semantic response caching in Redis

Accepted. Run semantic lookup only after an exact miss, embed through an explicitly configured provider, and scan a bounded recent Redis window. Consequence: similarity reuse without another datastore; embedding and cache failures fail open and candidate search remains deliberately bounded.

ADR-0021 — External cache telemetry for routing

Accepted. Extend ADR-0007 with opt-in vLLM KV-event and LMCache occupancy scorers whose network I/O runs in background tasks. Consequence: exact/capacity-aware routing stays allocation-light and fail-open; stale or untrusted telemetry becomes neutral and least-load selection remains available.

ADR-0022 — Uniform config-vs-DB tiering for models, providers, and provider groups

Accepted. Give models, providers, and provider groups the same two-tier config wrapper over the DB (CRUD) tier: readonly entries are immutable and config-owned; default entries are seeded into the default project once at startup and then owned/editable via API/UI; pure DB rows come from CRUD. readonly wins resolution; a default colliding with a readonly key is a load-time error. Top-level [[routes]]/[[providers]]/[[provider_groups]] stay as deprecated readonly aliases for back-compat. Consequence: providers gain a seed-then-edit story and provider groups gain a full DB lifecycle (new tables/repos/seed/CRUD, tracked as follow-up PRs); the model tiering pattern is reused rather than reinvented per entity.

ADR-0023 — Propagating access-profile model policy to the data plane

Accepted. Resolve each virtual key owner’s merged access-profile policy when the snapshot is built, carry it on the key record, and enforce it on the gateway’s model and route selection; the key’s own allow-list and the owner’s policy must both permit a model. Rejects key-mint-time resolution because a later policy edit would never reach issued keys, so a revocation would silently fail to revoke. Adds bump_config_version() triggers to exactly the four tables that feed the resolution, changing migration 0058’s premise rather than its rule. Consequence: the policy /rbac/effective reports is the policy enforced, and a deployment with no access profiles is unaffected.

ADR-0024 — Dashboard UX telemetry as a structural-only event stream

Accepted. Collect dashboard usability events into a ui_events ClickHouse table beside request_logs rather than adding a product-analytics vendor, and make the schema itself the privacy guarantee: every column is a key, an enum, a duration or an id, so a form value or prompt has nowhere to land. Attribution is server-side; the ingest endpoint is guarded by CurrentUser with no capability row, because a create capability granted to any authenticated caller would break the RBAC invariant that a viewer writes nothing. Consequence: usability data with no new vendor, egress or retention policy, joinable to gateway traffic on trace_id; the cost is that any question needing a value rather than a key is unanswerable by design.

ADR-0025 — Events as logs, not span events

Accepted. OpenTelemetry is deprecating the Span Events API, so log-based events become rolter’s event model: no new explicit add_event/record_exception, and #809 (OTLP log export) sequences before #808 (GenAI conventions) because those conventions are specified as log-based events and cannot be emitted until logs are exported at all. A grep finds zero uses of the deprecated API, but tracing-opentelemetry turns every tracing event fired inside a span into a span event, so rolter is in practice an exclusive implicit user of it — which makes the migration a dependency upgrade in one bridge rather than a sweep of 57 call sites. Consequence: tracing stays; #809’s trace_id/span_id correlation becomes a blocking acceptance criterion rather than a nicety; new event types have no correct home until it lands, and should wait rather than add a span event that must later be removed.

ADR-0026 — Client control over telemetry: a kill switch now, collector-routed tenant destinations later

Accepted. Splits #812 in two. ROLTER_TELEMETRY_ENABLED ships now as one explicit off for every signal — traces, metrics, the dashboard’s browser tracing, and logs once #809 lands — that can only subtract, defaults to enabled so no existing deployment changes, and leaves export on for an unrecognized value so a typo cannot silently blind a deployment. It is environment-only despite the issue asking for a config key: telemetry::init installs the subscriber before any config file is read, so a config-file switch could not gate trace export at all. Per-tenant destinations are deferred and, when built, belong in the OpenTelemetry Collector’s routing processor keyed on an org resource attribute rather than in-process exporters — that keeps one egress path in the gateway and keeps tenant-supplied URLs, an SSRF surface, out of the data plane entirely. Consequence: operators get a reviewable “no telemetry leaves here” today; per-tenant routing later requires running a collector, which is already the recommended topology.

ADR-0027 — End-to-end test harness: Python/uv project driving a black-box stack

Accepted. The e2e suite is a Python driver on the latest CPython managed by uv, exercising the real HTTP APIs of a docker-compose stack (postgres, redis, clickhouse, control, gateway, and N llm-d-inference-sim fake-vLLM engines) with no in-process shortcuts, so the tests see exactly what an operator or tenant sees. Numbered after ADR-0026 despite its earlier date because ADR numbers are append-only and never reused. Consequence: the suite needs a container runtime rather than cargo test alone; in exchange it covers the wiring between the two binaries and the three datastores that unit tests cannot reach.

ADR-0028 — Disaggregated prefill/decode routing belongs to the engine, not the gateway

Accepted. rolter will not implement P/D disaggregation; a disaggregated fleet is one upstream and its own coordinator owns phase selection and the KV handoff. Engines move KV tensors through connector-selected transports such as NIXL/UCX. Some can coordinate that transfer through engine-specific HTTP fields, but rolter would still have to own compatible-worker topology, version-specific metadata, two upstream calls and per-request handoff state — exactly the lifecycle coupling ADR-0014 prevents. The distinguishing test is whether rolter can consume an input through a stable, engine-independent contract without joining the engine’s request lifecycle. Consequence: disaggregated fleets work with rolter today for any engine, with no code and no mid-request-handoff failure modes; revisit if a stable phase-placement contract emerges on the OpenAI or Anthropic surface.

ADR-0029 — An explicit per-provider opt-out from a hosted kind’s host pin

Accepted. ProviderKind::Openrouter pinned api_base to the literal https://openrouter.ai/api/v1, which closes a real exfiltration path — a hosted kind names one operator, so an edited base URL would send that operator’s key elsewhere — but also ruled out an egress proxy, a vendor-compatible endpoint, and testing the dialect at all, so the #924 dogfooding fleet had to declare the endpoint openai_compatible and left the adapter unexercised. Adds allow_custom_api_base = true per provider: the safe default is unchanged, the opt-out is one reviewable line, only the host pin is relaxed (the api_key_env rule still holds), and rolter check warns on every provider that sets it while --strict fails. Rejects a loopback/private-range carve-out, which would not help a corporate proxy and would key the rule on where rolter runs rather than on intent. Consequence: the OpenRouter dialect gets local coverage; providers stored in Postgres keep the safe default until the column, CRUD surface and dashboard control land as their own change.

ADR-0030 — A served OpenAPI document for the control-plane API

Accepted. The gateway already serves a hand-built OpenAPI 3.1 document at /openapi.json with an embedded Scalar reference at /docs; the control plane serves the same for its ~160-path management API, built from one operations table in crates/rolter-control/src/openapi.rs rather than from a second OpenAPI toolchain. Rejects hand-written SDK surfaces (#421/#422/#851 would each transcribe 240 operations, three times, forever) and utoipa derivation (a proc-macro graph plus a schema describing rolter’s Rust types rather than its wire contract, which diverge wherever a handler flattens or hides a field). Completeness is a merge gate, not a hope: a test reads every route registration under src/ and fails naming any (path, method) missing from the document, with the reverse check for stale rows. Consequence: adding an endpoint costs one row and forgetting it fails the build; body schemas are typed for the stable CRUD surfaces and open objects for the dynamic ones, which the gate makes a visible, enumerable debt rather than a silent gap.

ADR-0031 — One model for turning a subsystem off

Proposed. A survey for #1073 found eight different ways a rolter subsystem can be turned off — a deployment-wide flag, a per-subsystem enabled, an Option left unset, an empty collection, a numeric sentinel, a per-object row, a cargo feature, and absent infrastructure — with no written boundary between them, and ten subsystems (MCP gateway, PII sanitizer, guardrail webhooks, semantic cache, budgets, rate limits, plugins, payload capture, realtime, analytics) having no deployment-wide switch at all. Collapses these to two layers: capability (compile-time or infrastructure, read-only, extending the existing unavailable_flags()) and enablement (one DB-backed boolean per subsystem, hot-reloaded through /internal/snapshot). Defines “off” per class rather than per subsystem — filters and optimisations bypass, gateway surfaces answer 501 naming the flag rather than a 404 indistinguishable from a typo, enforcement bypasses audibly, recorders drop at ingest, UI-only surfaces hide — under one rule: a disabled subsystem never changes an answer the client already had a right to. Per-object enabled rows stay and are explicitly outside the model. Rejects a bare per-subsystem enabled (cannot express capability), a generic HashMap<String, bool> (loses the compile-time flag-name guarantee and the default-on test), and a second SPA bootstrap payload. Consequence: default-on is guaranteed by construction and by a test over FeatureFlagsConfig::default(), so an upgrade cannot change behaviour; the cost is ten subsystems each needing a flag, a bump_config_version() migration, snapshot plumbing and a dashboard switch, filed as sized follow-ups.

ADR-0032 — What 1.0.0 guarantees, surface by surface

Accepted. 1.0.0 is a compatibility claim semver assigns a meaning to whether or not one is written, so #922 writes one per surface. The Rust crates are internal: no crate offers a stable Rust API at 1.0 or during 1.x, said in each published crate’s description, its //! docs and the README, because a shared version number cannot carry the caveat — and both mechanisms that would otherwise enforce the unmade promise are made to agree, with semver_check = false for release-plz (a Rust API diff must never major-bump the product) and the semver-checks job permanently advisory rather than promoted at 1.0 as api-stability.md had planned. /v1/* guarantees the dialect, not a schema: the v1 is OpenAI’s and there is no /v2/ escape hatch, so rolter promises fidelity and tracking — following an upstream dialect change is not a rolter breaking change — while the rolter-owned parts (virtual-key auth, provider-slug/model addressing, the error envelope, /v1/models) are promised like the control API. /api/v1/* is additive within v1, and removing anything published in the OpenAPI document takes two minor releases and 90 days, whichever ends later, announced in the release notes, deprecated: true in the document and served with Deprecation/Sunset headers; both bounds because a release count is worthless at an unfixed cadence and a time period can pass with no release. A 1.0 rolter.toml is read by every 1.x on the same terms; the schema moves forward only, with restore-from-backup as the sole rollback. After 1.0 BREAKING CHANGE: means exactly one thing — a change to a promised surface shipping without a completed deprecation window — never a Rust API change, an upstream dialect change, or a subsystem carrying the experimental marker, which stays the single documented exemption route. Consequence: internal refactors such as #1041/#1042 stay unblocked, the deprecation machinery (Deprecation/Sunset, deprecated: true, a release-notes section) becomes prerequisite work for the first deprecation rather than for 1.0, and removing a stability marker becomes the expensive act.

ADR-0033 — The merge queue is how master stays green

Accepted. ci-ok is evaluated per pull request against whatever base each branch was cut from, and branch protection does not require a branch to be up to date, so two individually-green PRs can combine into a red master — which has now happened twice (#1150’s ProviderConfig field, and the OpenAPI completeness gate landing against two newly-mounted routes in #1310/#1311/#1299). The exposure is structural: this repo deliberately carries several tests that assert a global property of the tree, and every one of them is a guard two independent changes can each satisfy alone and jointly violate. merge-protection.md had set the condition for revisiting at two more incidents; it is met. Enables GitHub’s merge queue on master and keeps strict = false: ci.yml gains a merge_group: trigger so the same ci-ok runs against the synthetic tree the queue will actually produce, batched rather than per PR. Rejects the up-to-date requirement, which is strictly worse here — one gate run per PR instead of one per batch, a hand rebase per author, and last-merge-wins thrash that can be unsatisfiable when master moves faster than a gate run takes. The title-edit fast path is fenced to pull_request so a merge-group tree can never inherit a verdict from a run that never saw it, and both agent-session-url checks follow the queue because a squash merge writes the PR body into history. Consequence: merging becomes asynchronous (gh pr merge enqueues), latency per merge rises by one gate run while throughput does not fall, stacked PRs are unaffected because a child targets an unprotected parent branch, and the master push run stays as the backstop for anything that bypasses the queue.

ADR-0034 — CI is sized to the 20-slot runner pool

Accepted. The organisation’s Free plan gives every workflow run one pool of 20 concurrent jobs, yet one PR push started 36 and an isolated merge to master up to about 98 (about 57 on the measured average), because release-plz re-ran all of quality.yml as verify and then dispatched a 35-job run on the release PR. From 09-26 to 09-28 the 26 master push gates lost a median 21.5 min each to queueing, 17 jobs did under 20 s of work each, the helm job built the maturin wheel by accident, and the Actions cache sat over its 10 GB limit. Collapses the gate into fewer, larger jobs (static checks, rust lint, rust build, nextest, image smoke, one bun job, coverage on PRs only, two codeql legs with an in-job Rust skip on PRs that touch no Rust), moves macOS, compose smoke, msrv and master coverage to a nightly extended.yml, replaces verify with a release-gate bound to the ci.yml push run’s ci-ok job, runs pr-title and the session-url checks inside ci-ok against the live PR body, dispatches release-PR CI only under release:ready, and saves Rust caches from master only: 11 jobs per PR push and 13 per isolated merge (23 while the release PR carries release:ready). ci-ok keeps its name and id, gate-ok keeps its name for the fast path, every former check stays a named step under !cancelled() with a report step that fails on a skipped must-run step, quality.yml stays secret-free, semver-checks stays advisory per ADR-0032, and no blocking check is skipped by diff. Rejects paths: filters (a skipped workflow leaves ci-ok pending) and diff-based skips (a skipped job reads as success, #1562), a check-run name lookup for the release gate, self-hosted runners on a public repository, and ARM or slim runners that draw on the same pool. Consequence: on 09-28 the measured master push gate median was 16.9 min and the simulation puts it at 9.1 after the change, and in the 09-26/27 burst 27.8 against a simulated 11.6; a failing check is a step inside a job, so the report step carries the per-check signal; msrv checks 1.82 for the first time and will go red (#2026); and the merge queue of ADR-0033, still off (#2029), should run with build concurrency 1-2 once enabled.

OpenAI Responses API translation

Status: Development · Date: 13 Jul 2026 · Issues: ROL-252

Context

ROL-252 adds the public POST /v1/responses. The gateway already routes on model, and rolter-proxy already holds an isolated registry of OpenAI Chat Completions and Anthropic Messages translations with incremental SSE handling. Responses carries text, multimodal input, tools, tool results and its own streaming event model. Lifecycle operations use a response identifier without a model, so they cannot safely select a route without storing the tenant/key → upstream response association.

Options considered

Option 1 — Native passthrough only

Forward /v1/responses to every provider without translation.

flowchart LR
    C[Responses client] --> G[rolter gateway]
    G --> P[upstream /v1/responses]

Option 2 — Protocol registry with Responses

Add Responses as a protocol: native OpenAI gets passthrough, while Chat Completions and Anthropic Messages get adapted requests and return Responses objects or SSE events.

flowchart LR
    C[Responses client] --> G[rolter gateway]
    G -->|OpenAI| R["/v1/responses"]
    G -->|Chat-compatible| CC["/v1/chat/completions"]
    G -->|Anthropic| AM["/v1/messages"]
    R --> G
    CC --> G
    AM --> G

Comparison

OptionProsCons
1. Native passthrough onlyNo translation and no loss for a Responses-native upstreamChat and Anthropic routes do not support the new surface
2. Protocol registry with ResponsesOne public contract for OpenAI, Chat and Anthropic; transport, metrics and accounting stay sharedNot every Responses capability has an equivalent; an SSE converter has to be maintained

Decision

Option 2 was chosen. Protocol::OpenAiResponses is added to the rolter-proxy registry. ProviderKind::Openai uses the native /v1/responses; Chat-compatible providers use /v1/chat/completions; Anthropic uses /v1/messages. Common fields are translated, and responses and streaming events are returned in the Responses representation.

Rationale

This preserves the existing architectural boundary: the gateway owns authentication, tenant scope, target selection, tracing, metrics and accounting, while rolter-proxy owns only the wire protocol. Native Responses loses no provider-specific fields, and the existing Chat and Anthropic routes stay reachable for clients on the new OpenAI SDK.

Consequences

Benefits:

  • one shared Responses endpoint works across three families of upstream APIs;
  • SSE is translated incrementally, without buffering the live stream;
  • input_tokens/output_tokens are counted by the existing cost and rate-limit accounting.

Drawbacks and risks:

  • background, store, previous_response_id and provider-specific reasoning have no safe Chat/Anthropic equivalent and are not forwarded there;
  • lifecycle for native OpenAI is implemented by a separate tenant-scoped registry in ADR-0016; translated Chat/Anthropic resources still return 501 response_lifecycle_unsupported;
  • supporting new Responses event types requires extending the converter and its tests.

System impact:

rolter-gateway, rolter-proxy, the OpenAPI spec and the API documentation change; the storage schema does not.

  • ADR-0014 — Extensible API protocol translation
  • ROL-252 — OpenAI Responses API passthrough and streaming

Open questions

  • Consider a distributed backend registry for multi-replica deployments without sticky routing.
  • Extend streaming translation of function-call and reasoning event types as supported upstream contracts are confirmed.

Routing OpenAI Responses resources through a tenant-scoped registry

Status: Development · Date: 13 Jul 2026 · Issues: ROL-252, ROL-264

Context

ROL-252 added OpenAI Responses creation, routed on the model field. ROL-264 adds GET and DELETE /v1/responses/{id}, POST /v1/responses/{id}/cancel and GET /v1/responses/{id}/input_items. These requests carry no model, tenant or provider, and the upstream identifier cannot safely be used to re-balance: the request could go to a different provider, target or provider key and expose another tenant’s resource.

The gateway already authenticates the virtual key, atomically refreshes the routing snapshot and selects a concrete provider credential. A safe lifecycle requires remembering that decision after a successful POST /v1/responses, streaming responses included. Translated Chat Completions and Anthropic Messages do not create a persistent upstream Responses resource.

Options considered

Option 1 — Re-select the route from the response ID

Hash response_id and use the current balancer or iterate over providers.

flowchart LR
    C[Lifecycle client] --> G[rolter gateway]
    G --> B{Current balancer}
    B --> P1[Provider A]
    B --> P2[Provider B]

Option 2 — Bounded process-local tenant registry

After a successful creation, store the composite key virtual-key digest + response_id and pin it to the provider, target, model and provider-credential fingerprint. The entry has a TTL and is removed after a successful DELETE.

sequenceDiagram
    participant C as Client
    participant G as Gateway
    participant R as Tenant registry
    participant P as Native OpenAI provider
    C->>G: POST /v1/responses with model
    G->>P: create via selected target and credential
    P-->>G: response ID or SSE response event
    G->>R: store tenant plus pinned route and capabilities
    C->>G: GET, DELETE, cancel or input_items by ID
    G->>R: lookup by authenticated tenant plus ID
    R-->>G: pinned native route
    G->>P: lifecycle request with same credential

Option 3 — Distributed registry in Redis

Store the same entries in shared Redis with a TTL, so that any gateway replica can serve a lifecycle request.

flowchart LR
    C[Client] --> G1[Gateway replica 1]
    C --> G2[Gateway replica 2]
    G1 --> R[(Redis registry)]
    G2 --> R
    G1 --> P[OpenAI provider]
    G2 --> P

Comparison

OptionProsCons
1. Re-selectionNo new stateTenant isolation, provider and credential cannot be guaranteed; unsafe
2. Process-local registryAdds no network hop and no mandatory infrastructure to the data plane; the synchronous write is available as soon as the response completesRequires sticky routing between replicas; entries are lost on restart
3. Redis registryWorks across replicas and survives a gateway restartRedis becomes mandatory for lifecycle; adds latency, a failure mode and a write race after SSE completion

Decision

Option 2 was chosen. rolter-gateway keeps a bounded process-local registry. The key is composed of a peppered digest of the virtual key and the public response_id; the plaintext key is not stored. The value records the provider, target, public model, provider-native ID, the fingerprint of the selected provider credential and capability flags.

By default an entry lives for 24 hours and a single process holds at most 100,000 entries. Both are configured through [responses] registry_ttl_secs and registry_max_entries; a zero value disables registration. Expiry and overflow are cleaned up lazily on lookup/insert. A successful DELETE removes the entry immediately.

Rationale

A process-local registry closes the main security invariant without requiring Redis on the inference path. The composite key makes unknown, expired and cross-tenant lookups indistinguishable. Pinning to the provider credential prevents reaching the resource through a different upstream account. The fingerprint makes it possible to find the same credential after the key pool is reordered, without storing the secret a second time.

Translated Chat Completions and Anthropic Messages get an entry with empty lifecycle capabilities. That allows returning a precise 501 response_lifecycle_unsupported to the owner while keeping a uniform 404 response_not_found for any other tenant.

Consequences

Benefits:

  • a lifecycle request is never re-balanced and always uses the original provider account;
  • unknown, expired, deleted, cross-tenant and post-config-change unreachable resources all return the same 404 with no route metadata;
  • the native upstream status and body are forwarded without translation;
  • non-streaming JSON and completed SSE are registered from a single completion-observer point.

Drawbacks and risks:

  • multi-replica deployments require sticky routing by client or response ID;
  • a gateway restart drops the registry earlier than upstream retention;
  • the full response is temporarily buffered by the existing accounting stream; the registry reuses that buffer, but the memory footprint is not reduced;
  • deleting a provider, changing its kind or rotating the original credential makes the entry unreachable and returns 404.

System impact:

rolter-core (retention configuration), rolter-gateway (registry, lifecycle handlers and response observer), rolter-proxy (model-less forwarding), the OpenAPI spec, the API documentation and the engine smoke suite change. The Postgres/Redis schemas do not.

  • ADR-0005 — Org → Team → Project → Virtual Key tenancy
  • ADR-0014 — Extensible API protocol translation
  • ADR-0015 — OpenAI Responses API translation
  • ROL-252 — OpenAI Responses API passthrough and streaming
  • ROL-264 — Responses API lifecycle resources

Open questions

  • Add an optional Redis registry backend if lifecycle must work without sticky routing across replicas.
  • Extend capability discovery once OpenAI-compatible providers reliably implement the native Responses lifecycle.

Provider/model addressing to disambiguate identical model names

Status: Accepted · Date: 14 Jul 2026 · Issues: ROL-266

Context

When several providers serve the same model name, a client has no first-class way to say which one it wants. This is common with self-hosted OpenAI-compatible backends: two vLLM/SGLang instances at different base URLs both serving qwen3, or an openai-kind provider whose base_url points at a non-OpenAI upstream.

How rolter addresses models today

  • A client sends model = a route name (routes.model, unique per project).
  • A route fans out to one or more route_targets, each {provider_id, upstream_model?, weight}.
  • The proxy rewrites the outgoing model to upstream_model when set (crates/rolter-proxy/src/lib.rs:333, maybe_rewrite_model); target selection and failover happen in crates/rolter-gateway/src/handlers.rs (weighted balancer over entry.route.targets).
  • Providers are unique(org_id, name); there is no URL-safe slug.
flowchart LR
    C[client: model = route name] --> R[route lookup]
    R --> B[balancer over targets]
    B --> T1["target A · provider vllm-msk · upstream qwen3"]
    B --> T2["target B · provider vllm-spb · upstream qwen3"]

So disambiguation today means the operator invents distinct route names (qwen3-msk, qwen3-spb). There is no client-facing provider/model addressing and no convention linking a route name to a concrete provider.

Prior art

  • LiteLLM — a two-tier scheme: clients send a public model_name alias; each deployment carries litellm_params.model with a provider prefix (openai/gpt-4o) plus its own api_base/credentials. Multiple deployments sharing one model_name are load-balanced. The prefix is a routing hint tied to a base_url, so you can point openai/... at a Qwen endpoint by swapping base_url — the prefix is semantically muddy and is not a stable identity.
  • Envoy AI Gateway — extracts the model field from the request body into an x-ai-eg-model header before routing, then applies ordinary header-match rules in an AIGatewayRoute to pick a backend. Selection is out-of-band (header/route config), not encoded in the model string the client sends.

The lesson: keep the address segments stable identities, not free-form hints (avoid LiteLLM’s base_url ambiguity), while still letting a route fan out.

Options considered

Option A — Status quo (distinct route names)

Operators keep inventing unique route names per provider (qwen3-msk).

  • Pro: zero code; already works; route name stays the single routing key.
  • Con: no convention; clients must know deployment-specific names; poor discovery; the provider is invisible in the address.

Option B — First-class provider-slug/model addressing (coexisting)

Introduce a stable, URL-safe slug on providers. A client may send either a bare route name (unchanged) or provider-slug/model. The gateway resolves provider-slug/model to the concrete (provider, upstream_model) pair, pinning the provider and using model as the upstream model.

flowchart LR
    C["client: model = 'vllm-spb/qwen3'"] --> P{"contains '/' and<br/>left = known provider slug?"}
    P -- no --> RN[resolve as route name today]
    P -- yes --> PR["pin provider vllm-spb"]
    PR --> UM["upstream model = 'qwen3'"]
    UM --> FW[forward to that provider only]
  • Pro: unambiguous, self-describing addressing; slug is a stable identity (fixed kind + base_url), avoiding LiteLLM’s base_url muddiness; coexists with named routes, so it is additive and backward-compatible; maps cleanly to “pick a provider from the list, or add one inline, then reference its models” in the UI.
  • Con: needs a new slug column (migration + uniqueness/charset rules + CRUD/UI); parsing precedence and slash-collision rules to define; a pinned provider bypasses multi-provider fan-out (see open questions — it can still fan out across that provider’s own key pool / targets).

Option C — Auto-derived provider/model aliases (read-only convenience)

No new column: derive an alias by slugifying the existing provider name and pairing it with each target’s upstream_model, exposed only through /v1/models and accepted on input.

  • Pro: no schema change; quick.
  • Con: provider name is mutable and only unique(org_id, name) — renames silently break addresses; slugifying a display name yields collisions and unstable ids. Same fragility LiteLLM has. Rejected as the primary path.

Option D — Out-of-band provider selector header

Keep model as today; add an optional x-rolter-provider: <slug> header (Envoy-style) to pin the provider.

  • Pro: no change to the model string; no slash-parsing.
  • Con: not expressible in stock OpenAI/Anthropic SDK model fields, so clients that can only set model (the common case) cannot use it; discovery is worse. Useful as a complement to B, not a replacement.

Comparison

CriterionA (route names)B (slug/model)C (derived)D (header)
Client-facing disambiguation✗✓✓✓ (header only)
Stable identity (rename-safe)n/a✓✗✓
Works via stock model field✓✓✓✗
Backward compatible✓✓✓✓
Schema/UI costnoneslug column + CRUD/UInoneheader plumbing
Avoids LiteLLM base_url ambiguityn/a✓✗✓

Recommendation

Adopt Option B — first-class provider-slug/model addressing that coexists with today’s named routes — and optionally add Option D later as a complementary header for clients that need to pin a provider without touching model.

Rationale: B gives unambiguous, self-describing, rename-safe addressing that fits the OpenAI/Anthropic model field clients already use, and matches the desired UX (“pick an existing provider or add one inline, then reference provider-slug/model”). It is purely additive: existing bare-model routes keep working.

Proposed resolution semantics (to confirm)

  1. Precedence: try the whole model string as a route name first (preserves any existing route whose name contains /). If unmatched and the string contains /, split on the first /: if the left segment is a known provider slug in the caller’s scope, treat the right segment as the upstream model and pin that provider; otherwise fall through to the normal not-found path.
  2. Slug: ^[a-z0-9][a-z0-9-]{0,62}$, unique(org_id, slug), immutable-by-default (renaming display name never changes the slug). Backfill existing providers from a slugified name with numeric de-dup on migration.
  3. Balancing: a provider-slug/model request pins the provider and bypasses cross-provider fan-out, but still uses that provider’s key pool, cooldowns, and (if the provider has multiple same-model targets) intra-provider selection. Bare route names keep full multi-provider balancing.
  4. /v1/models: list both — existing route ids and provider-slug/model ids (grouped by provider in a follow-up UI), so either address is discoverable.

Slug collision handling

Slugs are unique(org_id, slug), so the DB is the final arbiter — but “just add a unique index” leaves the behaviour around collisions unspecified. This section pins it down for the three moments a collision can happen. Note first that slugs are org-scoped: there is no cross-org collision, and provider-slug/model always resolves within the caller’s org (enforced in the query where org_id = $caller), so the blast radius of any collision is a single org.

1. Migration backfill (deterministic). Shipped in crates/rolter-store/migrations/0014_provider_slug.sql:

  • Providers are processed per org in a stable order (id ascending via row_number() over (partition by org_id, base order by id)), so the result is reproducible.
  • Slugify name: lowercase, map every run outside [a-z0-9] to a single -, trim leading/trailing -; an empty result falls back to provider.
  • The first claimant of a base slug keeps it bare (truncated to 63); each subsequent collision gets a -N suffix from its row number (vllm, vllm-2, vllm-3, …), the base truncated to 58 chars so base-N still fits. The migration never fails on a collision — it always converges.
  • Follow-up: emit a migration report (one line per adjusted provider: org_id, provider_id, name, base_slug, final_slug) so operators can reconcile any externally-published address.

2. Runtime creation (validate, never silently mangle). Shipped in crates/rolter-control/src/crud.rs:

  • An explicit slug is validated against ^[a-z0-9][a-z0-9-]{0,62}$; an omitted one is derived from name (a name with no ascii alphanumerics requires an explicit slug). A conflict surfaces as the DB unique-violation error — the API never auto-appends a suffix behind the operator’s back, because an address is a contract the human picks explicitly.
  • Follow-up: map the unique violation to 409 with the next free suggestion ({"error": "slug taken", "suggestion": "vllm-2"}), and have the UI pre-check availability before submit.
  • Slug is immutable by default (it is the stable identity). An in-place change requires the explicit allow_slug_change=true opt-in on update, which warns that the provider-slug/model address changes with it.

3. Deletion & reclaim (explicit, not automatic). A hard-deleted provider frees its slug immediately; the next create may reuse it. This is intentional but load-bearing: reusing openai after deleting the old openai repoints that address to a new upstream. Therefore:

  • Prefer soft-delete for providers that ever served traffic, so a freed slug is not silently re-pointed; a reused slug is then an explicit operator action on a tombstoned name.
  • Reclaim is a new identity, never a restore — document that provider-slug/model after reclaim may resolve to different weights/base_url than before.

Decision (14 Jul 2026)

  • Adopt Option B, coexisting with named routes. provider-slug/model resolves to a pinned (provider, upstream_model); bare route names keep full multi-provider balancing. Named routes are not replaced.
  • Balancing when pinned: a provider-slug/model request pins the provider and bypasses cross-provider fan-out, but still fans out within that provider — its key pool, cooldowns, and any same-model targets stay in rotation.
  • Precedence & slug rules as proposed above (route-name-first, then first-/ split; slug ^[a-z0-9][a-z0-9-]{0,62}$, unique(org_id, slug), immutable by default).
  • Collision policy as in Slug collision handling above: slugs are org-scoped (no cross-org collision); migration backfill is deterministic with -N de-dup (shipped in migration 0014); runtime creation validates and rejects conflicts rather than silently suffixing (409 + suggestion as follow-up); slug changes need the explicit allow_slug_change opt-in; hard-delete frees a slug immediately, but soft-delete is preferred so a reclaimed slug is an explicit operator action, not a silent re-point.
  • Option D (header selector): deferred, not part of the initial implementation.

Addendum (20 Jul 2026) — provider groups: unifying slugs under one address

Problem

Per-provider slugs alone push fleet operators the wrong way. Run ten vLLM instances (distinct base_url + credentials each) and you get ten providers — vllm-1 … vllm-10 — where pinning any single one is the opposite of what the operator wants. Named routes can unify them, but a route binds one public model name to targets, so a cluster serving 30 models needs 30 hand-made routes. LiteLLM’s “model group / alias” answers the per-model case; nothing answers “this whole fleet, all of its models, one address”.

Decision

Introduce provider groups — an org-scoped, named set of providers that shares the slug namespace with providers (unique(org_id, slug) enforced across both), so the left segment of X/model resolves through a single unified lookup and is never ambiguous.

  • Membership: groups are fully operator-defined — any slug, any member set — and membership is many-to-many: one provider may belong to several groups. Overlapping groups are the intended way to express scopes, e.g. vllm-cluster-msk (five Moscow instances), vllm-cluster-nsk (five Novosibirsk instances), and vllm-cluster-all (all ten) — the client picks the scope by address. No group nesting (groups of groups) initially: flat membership covers the scoping cases, nesting adds cycle-detection and resolution complexity for little gain; deferred.
  • Resolution: precedence unchanged — whole string as route name first, then split on first /. The left segment is looked up in the unified slug namespace: a provider slug pins that provider (as decided above); a group slug fans out across the group’s members, balanced with the group’s configured strategy (any rolter_balancer::LoadBalancer), honoring per-member weight, each member’s key pool, and cooldowns.
  • Model handling: default is passthrough — the right segment is forwarded unchanged as the upstream model (the homogeneous-cluster case: every member serves the same model set). An optional per-member upstream_model rewrite covers heterogeneous groups, mirroring route_targets.
  • Credentials: unchanged. Clients authenticate with a rolter virtual key; member credentials stay per provider. A group gives one address, one client key, N upstreams with N distinct upstream creds — which is the unification the fleet operator actually needs.
  • Relation to routes: a group is effectively a wildcard route family (vllm-cluster/*) without creating a route per model. Named routes remain the curated-alias layer for cherry-picked public names; groups cover fleets.
  • /v1/models: group addresses are listed as the deduplicated union of member-served models (vllm-cluster/qwen3, vllm-cluster/llama4, …) alongside provider-slug and route ids.
  • Collision interplay: groups also soften the collision pressure from the section above — individual instances can carry mundane slugs (vllm-msk-1), while the group owns the meaningful address (vllm-cluster, or even vllm).

Addendum (26 Sep 2026) — precedence within the caller’s tenancy (#1844, #1845)

Resolution runs within the calling key’s tenancy (Snapshot::resolve_for in crates/rolter-gateway/src/state.rs). A named route of another org, or one its admin narrowed to another project, counts as a miss rather than a refusal. It shadows neither the caller’s own slug/model address nor the builtin fake-llm, and the caller gets the same 404 model_not_found as for a model nobody configured. A pinned address of another org’s provider or group answers the same way. Route names may still contain / (Qwen/Qwen2.5-7B), and a route of the caller’s own org still wins over the caller’s own address.

Until per-org namespaces land (#1857), one slug namespace spans the whole deployment: providers and groups of every org plus the bootstrap file’s readonly rows. The control plane refuses a provider or group slug that any of them holds, and a route name that is the slug/… address of another org’s or a readonly provider or group, or the builtin. Keys from a config file carry no org, so for them every row is in scope, and the same holds for anonymous callers when require_auth is off. The refusal runs in one direction only: it keeps a new route off an address that already exists, but a provider or group created later with the slug of another org’s existing slug/… route is accepted. Callers in the new row’s org get their own address, since the other org’s route is out of their tenancy; org-less callers resolve the named route first and reach the other org’s route and its upstream. Refusing that slug instead would let any org reserve slugs by naming routes after them. The gateway indexes provider slugs first-wins in file order, bootstrap rows before database rows, and then group slugs, skipping any a provider already holds. A slug two rows still share from before the guard therefore resolves the same way on every build, and a legacy database provider keeps a readonly group’s slug.

Proposed follow-up implementation issues

  1. store: add immutable slug to providers — migration, unique(org_id, slug), deterministic reported backfill with -N de-dup, 409+suggestion on create-conflict, soft-delete-preferred reclaim, CRUD wiring. (relates to ROL-81, see Slug collision handling)
  2. proxy/gateway: provider-slug/model parsing + resolution with the precedence rule and provider pinning; interaction with maybe_rewrite_model/upstream_model; tests.
  3. gateway: extend /v1/models to surface provider-slug/model ids.
  4. ui: model-management surfaces the provider-slug/model address; inline add-provider flow. (relates to ROL-222)
  5. (optional) proxy: x-rolter-provider header selector (Option D).
  6. store/gateway/ui: provider groups — provider_groups entity (slug in the unified namespace, strategy, members with weights), group-slug/model resolution with passthrough + per-member rewrite, /v1/models union listing, group CRUD/UI. (see Addendum: provider groups)

Sources

Per-provider egress proxy pools

Status: Accepted · Date: 18 Jul 2026 · Issues: #305

Context

A provider may need several outbound proxies for regional reachability, IP-based rate limits, or resilience. The existing singular egress_proxy setting cannot spread traffic or recover when its proxy becomes unavailable. Proxy credentials must not enter database rows, config snapshots, API responses, or metrics labels.

Options considered

  1. Keep one proxy and rely on an external proxy load balancer.
  2. Select a random proxy independently for every attempt.
  3. Maintain a per-provider round-robin pool with local failure quarantine.

Decision

Adopt option 3. egress_proxies is the canonical ordered pool, while the legacy egress_proxy remains a backward-compatible one-element pool. Each member owns a cached reqwest client and may use HTTP, HTTPS, SOCKS5, or SOCKS5H. Requests start at the next round-robin member and retry connect or tunnel failures through the remaining eligible members. Three consecutive failures quarantine a member for 30 seconds; a successful request clears its failure streak.

Authenticated URLs are accepted only as whole-value ${ENV_VAR} references. Metrics identify members with a redacted stable label and never expose userinfo.

Consequences

  • Providers can spread traffic and survive an individual proxy failure without a separate egress load balancer.
  • Existing singular configuration continues to work unchanged.
  • Retry is deliberately limited to connection/tunnel failures; retrying arbitrary HTTP responses could duplicate a request whose upstream already processed it.
  • Quarantine state and round-robin position are process-local, so gateway replicas may temporarily make different choices.
  • Operators must inject authenticated proxy URLs into every gateway replica.

Bounded semantic response caching in Redis

Status: Accepted · Date: 18 Jul 2026 · Issues: #261

Context

The exact response cache only reuses byte-equivalent normalized requests. Similar prompts still reach an upstream model even when an existing response would be acceptable. Semantic reuse adds an embedding request and a nearest-neighbour search, but it must not make gateway availability depend on the embedding provider or Redis and must keep request-path work bounded.

Options considered

  1. Keep exact caching only.
  2. Introduce a dedicated vector database and approximate-nearest-neighbour index.
  3. Store embeddings alongside Redis cache entries and scan a bounded recent window.

Decision

Adopt option 3 as an opt-in route capability. Exact lookup always runs first. On an exact miss, rolter embeds the normalized prompt using an explicitly configured provider/model and compares cosine similarity against at most max_candidates recent entries in the route/key-isolated Redis index. A response is reused only when its score reaches threshold. Semantic metadata and the exact response share the route cache TTL.

Embedding, Redis, decoding, dimension, missing-entry, and stale-entry failures all fail open to normal upstream routing. Streaming responses enter both indexes only after the stream completes successfully.

Consequences

  • Similar prompts can avoid model cost and latency without a new datastore.
  • Exact-cache behavior and its faster lookup remain unchanged.
  • Search cost is predictable but linear in the configured candidate bound; this is not intended as a large corpus vector-search engine.
  • The embedding provider adds cost and latency on semantic misses and becomes part of cache-result quality, though never gateway availability.
  • Operators own the similarity threshold and must account for unsafe reuse in domains where small wording changes alter the correct answer.

External cache telemetry for routing

Status: Accepted · Date: 18 Jul 2026 · Issues: #258, #259 Supersedes: ADR-0007 for exact vLLM and LMCache-aware modes

Context

ADR-0007 deliberately introduced approximate prompt-prefix affinity without coupling the gateway to an inference engine. vLLM can now publish exact KV block residency events, while LMCache deployments can expose availability and occupancy. Using those signals can improve reuse, but blocking network calls or trusting stale telemetry on the request path would weaken gateway latency and availability.

Options considered

  1. Keep only the engine-independent approximate prefix trie.
  2. Query cache state synchronously during every routing decision.
  3. Consume telemetry in background tasks and expose bounded in-memory scorers.

Decision

Adopt option 3 as two opt-in strategies behind the existing balancer interface:

  • precise_cache_aware subscribes to the supported vLLM V1 three-frame ZMQ msgpack KV-event protocol, derives stable token-block identities, and scores the resident leading fraction of tokenizer-aligned request blocks.
  • lmcache_aware polls a rolter-defined HTTP JSON signal containing occupancy and cache_available, then scores available targets as 1 - occupancy.

Both sources update bounded process-local state outside the request path. Missing request token IDs, malformed input, disconnects, stale data, and vLLM sequence gaps produce a neutral score and preserve least-load fallback. A sequence gap clears the precise index until an explicit all-blocks-cleared boundary re-establishes trust.

Consequences

  • Exact vLLM residency and LMCache capacity can influence routing without request- path network I/O.
  • Approximate cache_aware remains available and engine-independent.
  • vLLM exact scoring requires token IDs from the same tokenizer and a compatible event protocol; version assumptions are part of the operator documentation.
  • LMCache integration depends only on the small documented controller contract, not an unstable internal LMCache API.
  • Telemetry state is local to each gateway replica and intentionally disposable; restart, staleness, or desynchronization temporarily reduces routing quality but does not prevent requests.

Uniform config-vs-DB tiering for models, providers, and provider groups

Status: Accepted · Date: 20 Jul 2026 · Issues: #306 Relates: ADR-0017 (provider/model addressing), ADR-0018 (granular CRUD)

Context

rolter bootstraps from a TOML config and (optionally) a Postgres store, merged at startup via MergedConfigStore. Today the three routable entity types treat the config/DB boundary inconsistently:

Entityreadonly (config-owned)default (seed→DB, editable)API/UI (DB)
models[[routes]] + [models.readonly][models.default] (seeded once)CRUD ✓
providers[[providers]]— (missing)CRUD ✓
provider groups[[provider_groups]] (ADR-0017 addendum)—— (no store)

Three problems:

  1. Providers cannot be seeded-then-edited. A [[providers]] entry is permanently config-owned and rejected by CRUD (require_not_config_owned), so an operator who wants to bootstrap a provider from config but later edit its credentials/base_url through the UI has no path.
  2. Provider groups have no DB tier at all — they exist only as read-only config (ADR-0017 addendum / #571), so they cannot be created via the API or UI.
  3. The model shape is idiosyncratic — top-level [[routes]] and a parallel [models.readonly] both mean “immutable config route”, which is redundant and does not generalize to providers/groups.

The desired behaviour is uniform across all three: an operator can declare an entry as readonly (immutable, served straight from config), as a default (seeded into the DB once at startup, then owned and editable via API/UI), or create it purely through the API/UI (a DB row with no config presence).

Decision

Adopt one uniform two-tier config wrapper for every routable entity type — models, providers, and provider groups — layered over the existing DB (CRUD) tier:

[models]
readonly = [ /* immutable, config-owned, rejected by CRUD */ ]
default  = [ /* seeded into the default project once, then DB-owned */ ]

[providers]
readonly = [ ... ]
default  = [ ... ]

[provider_groups]
readonly = [ ... ]
default  = [ ... ]

Semantics, identical for each entity type:

  • readonly — merged into the effective running config and into the gateway snapshot directly; tracked in ConfigOwned; CRUD create/update/delete against a name/slug in this set is rejected. Immutable for the process lifetime.
  • default — seeded into the bootstrap default/default/default tenancy exactly once (idempotent: an existing row by natural key is never overwritten on restart), then it is an ordinary DB row — editable and deletable via API/UI, and not config-owned. Mirrors today’s seed_default_models.
  • DB (API/UI) — pure store rows with no config presence, already the CRUD path.

Resolution precedence when the same natural key (routes.model, providers.slug, group slug) appears in more than one tier: readonly wins (it is immutable and must never be shadowed by a DB row), then the DB row, then a default that has not yet been claimed as a DB row. Seeding a default whose key already exists as readonly is a config error, surfaced at load time (as models.default already does against readonly routes).

Migration & back-compat

  • The current top-level [[routes]] and [[providers]] arrays are kept as deprecated aliases for their readonly tier, so existing configs and the bootstrap rolter.example.toml keep working unchanged. New docs steer operators to [models.readonly] / [providers.readonly].
  • [[provider_groups]] (introduced in the ADR-0017 addendum / #572, before this ADR) becomes the provider_groups.readonly alias; the config-only groups slice is forward-compatible with this decision.
  • Slugs remain the stable identity across tiers (ADR-0017): a default provider or group keeps its slug when it becomes a DB row, so its provider-slug/model / group-slug/model address is stable across the config→DB transition.

Consequences

  • One mental model and one code path shape (readonly merge + default seed + ConfigOwned guard) for all three entity types; the provider-groups and provider work reuses the model tiering rather than inventing per-entity rules.
  • Providers gain a seed-then-edit story; provider groups gain a full DB lifecycle.
  • New store tables/repos are required for provider groups (provider_groups + membership), plus seed functions for providers.default and provider_groups.default, plus group CRUD — tracked as separate PRs (see below).
  • The deprecated top-level arrays add a small amount of parse-time aliasing to maintain until a future breaking release removes them.

Follow-up implementation issues

  1. core: uniform tier wrapper for providers + provider groups (readonly/default), with top-level [[routes]]/[[providers]]/[[provider_groups]] kept as deprecated readonly aliases; load-time validation that a default never collides with a readonly key.
  2. store/control: providers.default seed tier — seed function mirroring seed_default_models; providers seeded this way are DB-owned, not config-owned.
  3. store: provider_groups + membership tables, repo, and MergedConfigStore wiring so DB-defined groups reach the gateway snapshot.
  4. control: provider-group CRUD (create/update/delete, slug validation shared with providers, 409+suggestion on slug conflict) + provider_groups.default seed.
  5. ui: provider-group management screens (create/edit/membership/strategy), surfaced alongside providers and models. (separate issue — not in the initial backend PRs)

Propagating access-profile model policy to the data plane

Status: Accepted · Date: 4 Aug 2026 · Issues: #791, #534 Relates: ADR-0022 (config-vs-DB tiering)

Context

#534 added org-scoped access profiles that can carry a model and route allow/deny policy. The control plane resolves the profiles a caller holds, merges their policies, and reports the result on GET /api/v1/rbac/effective — but nothing enforced it on the gateway, so the policy described access it did not actually control.

Enforcement was deferred because the obvious mechanism does not work. The RBAC tables deliberately carry no bump_config_version() trigger: control-plane authorization is evaluated per request against the live database, so there is nothing to propagate, and bumping the version on every role edit would wake the whole gateway fleet for a change it could not observe. Migration 0058 records that reasoning and states the tables must never grow such a trigger.

The gateway, meanwhile, never sees a user. It authenticates a virtual key and knows only what the snapshot told it about that key. A policy is a property of a person; a request carries a credential. Something has to bridge the two.

Decision

Resolve the merged policy per key owner when the snapshot is built, carry it on the virtual-key record, and enforce it on the gateway’s model and route selection.

Concretely:

  • rolter_core::ModelPolicy becomes the single definition of the policy shape, its merge rule, and its allow/deny matching. The control plane’s MergedPolicy is now an alias for it.
  • load_virtual_keys resolves each key’s created_by user to a merged policy in one batched query for the whole key set, and publishes it on VirtualKeyRecord::access_policy. None means unrestricted.
  • KeyMeta::model_permitted and KeyMeta::route_permitted gate the request path. The key’s own model allow-list and the owner’s policy must both permit a model: they are separate grants and neither may widen the other.
  • Migration 0060 adds bump_config_version() triggers to exactly the four tables that feed this resolution: access_profile_policies, access_profile_assignments, access_profiles, and memberships.

Why not resolve at key-mint time

#791 floated stamping the policy onto the key when it is minted. That is unsound: a profile edited after the mint would never reach keys already issued, so revoking a model would not revoke it. A policy change that fails to restrict is a security bug, and the failure is silent — the control plane would report the new policy on /rbac/effective while the gateway kept honouring the old one. Snapshot resolution has the opposite failure mode: the worst case is a bounded staleness window that the existing config-version machinery already closes.

Why this does not contradict migration 0058

0058 says these tables must never bump the config version because the data plane does not consume them. This ADR changes that premise rather than overruling the rule: the gateway now consumes them, and the repository’s standing convention is that any table the data plane reads must bump the version inside the write transaction. 0060 therefore applies the rule to the tables whose premise changed, and only those.

custom_roles and custom_role_grants stay untriggered. They decide control-plane authorization, which is still evaluated live per request, so 0058’s reasoning holds for them unchanged.

memberships is included because it is the second path by which a profile reaches a user: a profile assigned to a team applies to everyone in it, so adding a member changes that user’s effective policy without any row in the profile tables changing. Membership is not a hot table, the trigger is statement-level, and the gateway coalesces on the version number — so even a bulk SCIM sync costs one snapshot poll rather than one per member.

Consequences

  • The policy /rbac/effective reports is now the policy that is enforced. One implementation of “deny beats allow” serves the control plane, the store and the gateway, so the reported and enforced answers cannot drift.
  • A deployment with no access profiles is bit-for-bit unaffected: every key carries None, and model_permitted reduces to the pre-existing model_allowed check.
  • Editing a profile or a team membership now bumps the config version, so those edits wake the gateway fleet. This is the intended cost of making the policy observable; it is bounded to four tables, none of them on a request path.
  • Enforcement is keyed on created_by. A key with no owner — admin-created and config-defined keys — carries no policy, because there is no person whose profiles could apply. Restricting those remains the key’s own model list.
  • The staleness window is the snapshot poll interval, as it is for every other DB-backed config. A revocation is not instantaneous; it is as fast as a provider or route change.

Dashboard UX telemetry as a structural-only event stream

Status: Accepted · Date: 6 Aug 2026 · Issues: #805 Relates: ADR-0023 (access-policy propagation)

Context

Browser tracing (#807) tells us how long a dashboard screen took and whether it threw. It does not tell us whether the screen was usable: which screens are slow to become interactive, where people back out, which forms get abandoned, which error and empty states are actually reached. Those are the questions that decide what to build next, and spans cannot answer them.

Collecting that is ordinarily where a product-analytics vendor is added. For a self-hosted gateway that sits in the request path of its operators’ LLM traffic, a second data processor is a hard sell: a different retention policy, a different redaction story, a different jurisdiction, and an outbound connection operators did not ask for. Several rolter deployments are air-gapped, where it simply does not work at all.

There is also a real hazard specific to this kind of telemetry. UX event streams are where free text leaks. An “abandoned form” event is one careless field away from carrying what was typed into the form, and the dashboard’s forms hold provider API keys, virtual keys and prompts.

Decision

Collect UX events in-house, into a ui_events ClickHouse table beside request_logs, and make the schema the privacy guarantee.

Structural only. Every column is a key, an enum, a duration or an id. There is deliberately no column a form value, prompt or free-text body could be written into. target names the form, control or validation rule; it never carries what failed the rule. A future column that could hold free text is a change to this ADR, not a routine migration.

This is chosen over the usual alternative — accept arbitrary payloads and redact on write — because redaction is a policy that has to keep being correct as the dashboard grows, while a schema with nowhere to put a value is correct by construction. logging.payload_capture already exists for the case where someone genuinely wants raw bodies, and it is off by default; this stream is deliberately not that.

Client-stamped time, server-bounded. The interaction’s instant can only come from the browser — the batch that carries it may be seconds or hours younger — so each event is stamped as it is queued and the endpoint accepts that ts. Trusting it outright would let one laptop with a wrong clock sit at the top of every recent-events query forever, so a supplied instant is used only within a window around ingest time and otherwise replaced by it (#1224). The two instants are therefore not interchangeable, and a row is only ever as accurate as the clock that produced it.

Server-side attribution. user_id is taken from the authenticated session and a client-supplied one is ignored, so a caller cannot file events against another user.

Authentication, not authorization. The ingest endpoint is guarded by CurrentUser alone, following me.rs, and has no row in the capability table. The RBAC model holds that a viewer writes nothing — rbac_matrix.rs asserts exactly that — and a ui_event:create capability granted to any authenticated caller breaks the invariant. A viewer who could not file their own screen views would be absent from every funnel, which makes the data quietly wrong rather than absent. Keeping this off the capability table states plainly that it is not an operator surface.

On by default, with two off switches. Absent clickhouse_url the stream is inert. logging.ui_events = false is a deployment-level opt-out. It defaults on — unlike payload_capture — precisely because the schema cannot carry sensitive content, so the usual reason to make telemetry opt-in does not apply.

Consequences

Operators get usability data with no new vendor, no new egress and no new retention policy: same deployment, same TTL, same 90-day partitioning as request logs. Because events carry trace_id, a slow screen and the gateway request behind it are one join rather than two systems.

The cost is expressiveness. Any question that needs a value rather than a key is unanswerable by design, and some genuinely useful analyses are foreclosed. That is the intended trade: the class of incident this prevents — dashboard secrets in an analytics table — is worse than the analyses it forbids.

Adding a screen means adding a stable screen key rather than letting a URL through. screen, target and from_screen are LowCardinality columns capped at 96 characters, which a real URL does not fit; sending one would degrade the table, so the bound fails the write instead.

Per-tenant routing of this stream is out of scope and tracked separately (#812).

Events as logs, not span events

Status: Accepted · Date: 6 Aug 2026 · Issues: #814, #808, #809, #805 Relates: ADR-0024 (dashboard UX telemetry)

Context

OpenTelemetry is deprecating the Span Events API — Span.AddEvent, Span.RecordException and friends. Maintaining two overlapping ways to emit a correlated event (span events via the Tracing API, log events via the Logs API) produced split guidance for instrumentation authors and a slower-evolving event model. The replacement is events as logs: emitted through the Logs API, named, and correlated to the active span through context rather than attached to it. Semantic-convention authors are now explicitly told to document events as log-based events.

Nothing is being removed soon. The deprecation is phased, existing data stays valid in the OTLP trace model, and compatibility layers are a stated priority. This is direction-setting, not an outage.

What rolter emits today

A grep for the deprecated API finds nothing:

$ grep -rn "add_event\|record_exception" --include='*.rs' crates/
(no matches)

That result is misleading, and reading the bridge is what the audit actually required. rolter instruments through tracing + tracing-opentelemetry, and that layer’s on_event turns every tracing event fired inside an active span into an OTel span event (tracing-opentelemetry-0.33.0/src/layer.rs:1468, span.add_event(...); the pre-export path pushes onto builder.events identically). An ERROR-level event additionally sets the span status to error.

So rolter has zero explicit uses of the deprecated API and is, in practice, an exclusive user of it: the ~57 tracing event macros across rolter-gateway, rolter-proxy and rolter-core become span events whenever they fire inside one of the pipeline spans. The migration surface is the bridge, not our call sites.

Why the ordering between #808 and #809 changes

The GenAI semantic conventions (#808) are precisely the area moving to log-based events. Implementing them against span events would be building onto a deprecated API on the day it is written.

Emitting log-based events requires exporting logs at all, and rolter does not: there is no OTLP logs exporter in the workspace and signoz_logs is empty by construction. #809 therefore stops being an independent nice-to-have and becomes a prerequisite.

Decision

Log-based events are rolter’s event model. New instrumentation targets the Logs API; span events are legacy, produced only incidentally by the tracing bridge.

Concretely:

  1. No new explicit span events. add_event and record_exception are not to be called directly. Nothing calls them today and nothing should start.
  2. #809 (OTLP log export) sequences before #808 (GenAI conventions). The conventions #808 implements are specified as log-based events, so the log pipeline has to exist before they can be emitted correctly. #808’s acceptance criteria adopt log-based events as the target.
  3. tracing stays. The bridge is idiomatic Rust and the ecosystem’s own integration, not a bespoke house wrapper — the same distinction drawn in the #815 wrapping audit. It is where the span-event mapping lives, so it is also the single place a future migration happens: when tracing-opentelemetry routes events through the Logs API, rolter follows by upgrading, without touching 57 call sites.
  4. Correlation is the acceptance bar. A log-based event is only a replacement for a span event if it carries trace_id and span_id, which is already called out as most of the value in #809. An exported log that cannot be joined back to its span is a regression against what the bridge does today.
  5. Content stays off. The GenAI opt-in content attributes (gen_ai.input.messages / gen_ai.output.messages) remain disabled whether they are carried as span events or as log records. Moving event model does not move the privacy line.

Consequences

  • The migration is a dependency upgrade rather than a code sweep, because rolter never adopted the deprecated API directly. This is the payoff of having gone through tracing instead of calling OTel by hand.
  • #809 gains a second justification beyond “logs are missing”: it unblocks #808. Its trace_id/span_id correlation requirement is now load-bearing rather than a nicety, and should be treated as a blocking acceptance criterion.
  • Until #809 lands, error detail continues to reach backends as span events via the bridge. That is fine — the deprecation is phased and the data stays valid — but it means the GenAI work should not start early and improvise its own event channel.
  • Anything that wants a new event type before #809 has no correct home. The answer is to wait rather than to add a span event that will have to be removed.

Alternatives considered

Implement #808 now against span events, migrate later. Rejected. The conventions are specified as log-based events, so this builds a second migration into work that has not shipped yet, on the exact attributes most likely to be re-specified.

Emit both span events and log events during a transition. Rejected. Duplicate correlated events on both signals is the split-guidance problem OpenTelemetry is deprecating span events to escape, and it doubles egress for data that already joins on trace_id.

Drop tracing and call the OTel Logs API directly. Rejected. It is a large rewrite of every call site to avoid a bridge that will itself be updated upstream, and it contradicts the #815 finding that the ecosystem bridge is not the wrapping the “don’t wrap” guidance targets.

Client control over telemetry: a kill switch now, collector-routed tenant destinations later

Status: Accepted · Date: 6 Aug 2026 · Issues: #812, #805, #809 Relates: ADR-0019 (egress proxy pools), ADR-0025 (events as logs)

Context

Telemetry is currently an operator decision baked into the deployment: OTEL_EXPORTER_OTLP_ENDPOINT for the backend exporter and ROLTER_UI_OTEL_ENDPOINT for the dashboard. A client of the gateway has no say in either — they cannot turn tracing off for their own traffic, and they cannot have their spans delivered to a backend they own.

#812 raises two things of very different size, and filed them together so the relationship is on the record. This ADR splits them.

The switch. “Off” is implicit today: you achieve it by leaving an endpoint unset. That works but is not discoverable, does not survive somebody setting the endpoint for one signal, and gives an operator nothing to point at in a security review.

Per-tenant destinations. rolter is multi-tenant (org / team / project, virtual keys, RBAC) while telemetry is single-destination, which does not match. A team running rolter as shared infrastructure wants its spans in its backend; some tenants want no export at all for data-residency reasons; a customer debugging their own integration should not have to ask the operator to fetch traces. This half raises questions — scope, fan-out, egress safety, cardinality — that are design work rather than implementation.

Decision

Ship the kill switch now as an environment variable. Do not build per-tenant fan-out in rolter; route it in the collector when it is built.

1. ROLTER_TELEMETRY_ENABLED

One switch covering traces, metrics, the dashboard’s browser tracing, and logs once #809 lands.

  • It can only subtract. When false, no exporter is built regardless of which OTEL_* endpoints are set. It is never a second way to turn export on.
  • Unset means enabled, so no existing deployment changes behaviour. That is not a weaker default than it sounds: with no endpoint configured nothing is exported anyway, so the effective default remains “exports nothing”.
  • An unrecognized value leaves export on. A typo must not silently blind a deployment; only the explicit falsy spellings (0, false, no, off) disable it.
  • Environment-only, with no config-file key, despite #812 asking for “the matching config key”. telemetry::init installs the subscriber in main before any config file is read — the gateway has to be able to log a config parse failure — so a config-file switch could not gate trace export at all. Honouring one only for the signals initialized later would mean the same key meant different things depending on which signal you asked about, which is worse than not having it. The OTEL_* contract this composes with is environment-based for the same reason.

2. Per-tenant destinations belong in the collector

When the second half is built, it is configuration of an OpenTelemetry Collector that sits in front of the tenants’ backends, not a fan-out inside rolter.

  • Scope: org. It is the easiest to administer and the coarsest unit anyone actually asks about (“my team’s spans in my team’s Honeycomb”). Virtual key is finer and matches how traffic is attributed, but a destination per key is a cardinality problem with no matching demand. rolter’s job is to stamp the attribute; the routing key can be refined later without changing where fan-out happens.
  • Fan-out: the collector’s routing processor. Multiple SDK exporters in-process does not scale with tenant count — each is a connection, a queue and a retry buffer, all inside the request-serving process. rolter emits one OTLP stream to one collector with a resource attribute naming the tenant, and the collector’s routing processor sends each tenant’s data onward. This keeps exactly one egress path in the gateway and makes per-tenant destinations an operator’s configuration change rather than a rolter deploy.
  • Egress safety follows from that. A tenant-supplied endpoint is an arbitrary URL, and the deciding argument against in-process fan-out is that it would make the gateway POST to one. Terminating tenant endpoints in the collector keeps that SSRF surface out of the data plane entirely. If a tenant-supplied endpoint is ever accepted through the API, it must go through the same EgressPolicy that provider api_base values do (ADR-0019), and its credentials through the encrypted-secret path — never plaintext config.
  • Opt-out is the switch, one level down. A tenant wanting no export at all is a routing rule that drops, not a second mechanism.

Consequences

  • An operator gets a single, documentable, reviewable “no telemetry leaves this deployment” today, without waiting on the design work.
  • The gateway keeps exactly one exporter and one egress path no matter how many tenants exist. Cardinality and buffer growth become the collector’s problem, where they are a sizing question rather than a data-plane risk.
  • The cost is that per-tenant destinations require running a collector. That is already the recommended topology (docs/dev-docs/architecture/observability.md), so it is not a new dependency for anyone following it — but a deployment exporting straight to a vendor endpoint would have to add one.
  • Nothing here is blocked on #809, but when OTLP log export lands it inherits both decisions automatically: the switch covers it, and its records carry the same resource attributes the routing processor keys on. That is why the switch is worded per-deployment rather than per-signal.
  • ROLTER_TELEMETRY_ENABLED is now part of the public configuration surface and cannot quietly change meaning. In particular it must stay “can only subtract” — making it required-true would break every existing deployment on upgrade.

Alternatives considered

A config-file [telemetry] enabled key. Rejected on initialization order, as above. Worth restating because it is the obvious request and the reason it does not work is not obvious.

Per-signal switches (ROLTER_TRACES_ENABLED, …). Rejected. The problem #812 names is that “off” is currently spread across several variables; adding more variables reproduces it. Per-signal control already exists through the signal-specific OTEL_*_ENDPOINT variables.

In-process per-tenant exporters. Rejected on all three of scaling, egress safety and cardinality, as above.

Reflecting a tenant-supplied endpoint straight from a virtual key. Rejected outright: it turns every key holder into someone who can aim the gateway’s egress, which is the SSRF primitive ADR-0019 exists to prevent.

End-to-end test harness: Python/uv project driving a black-box stack

Status: Accepted · Date: 21 Jul 2026 · Issues: #613, #614

Context

Per-crate unit tests and the two narrow suites we already have (#414 in-process integration, #449 compose smoke) never exercise rolter as a whole: a freshly booted stack, multiple tenants, live RBAC changes, a fleet of upstream engines, and balancing across clusters. #613 needs a broad governance/routing E2E suite, and #614 is the harness every other scenario (RBAC matrix, balancing, key lifecycle, security) builds on.

Two decisions had to be made: what drives the stack, and how the driver code is packaged.

Options considered

  1. Rust in-process integration test — spin control+gateway in-process against testcontainers. Fast and CI-native, but not a real deployment: it bypasses the HTTP edge, the compose network, and the container boundaries where isolation and auth actually matter.
  2. Bash script driving docker-compose — closest to “run fresh rolter”, but bash grows unmaintainable for a table-driven RBAC matrix with JSON assertions.
  3. Python driver over docker-compose — real HTTP against a real composed stack, with a proper test framework for the matrix.

For packaging the Python driver:

  • PEP 723 inline script metadata (uv add --script) — deps live in a single file header, uv run file.py auto-installs. Ideal for one-file scripts.
  • A uv project (pyproject.toml + uv.lock) — deps and a lockfile for a multi-file package.

Decision

Adopt option 3: a Python driver, latest CPython managed by uv, hitting the real HTTP APIs of a stack brought up by docker-compose (postgres, redis, clickhouse, control, gateway, and N llm-d-inference-sim fake-vLLM engines). No in-process shortcuts — the tests see exactly what an operator/tenant sees.

Package it as a uv project under integration/e2e/, not inline PEP 723 script metadata. The harness is a pytest package: a shared helper/client library plus multiple scenario modules (test_rbac.py, test_balancing.py, test_keys.py, test_security.py) sharing fixtures. Inline metadata is per-single-file and cannot express cross-module fixtures or a lockfile, so it is the wrong grain here. Inline PEP 723 remains fine for any standalone one-off helper run by hand.

The harness project is kept separate from the root maturin pyproject.toml: that one packages the shipping wheel at requires-python >=3.9; the test harness has no such floor and pins the latest stable CPython via uv python pin.

RBAC only enforces when ROLTER_ADMIN_TOKEN is set (otherwise the control plane runs in open/superadmin mode, per #454/ROL-250), so the harness compose sets the admin token and shares ROLTER_KEY_PEPPER/ROLTER_SESSION_PEPPER between control and gateway. Bootstrap uses the admin token as superadmin; per-role scenarios create local accounts and drive them via session tokens.

The suite is heavy, so it is gated (manual dispatch + nightly), never on the default per-PR gate.

Consequences

  • One harness backs every #613 sub-issue; scenarios are Python modules, not new bespoke rigs.
  • Tests run against the real container edge, so cross-tenant isolation, auth, and balancing are exercised where they actually live.
  • Fake engines only (llm-d-inference-sim) — offline/air-gapped safe, no secrets, no model downloads, deterministic.
  • A lockfile makes the harness reproducible; the cost is a second Python project in the tree, deliberately isolated from the wheel’s packaging metadata.
  • Full stack-boot verification requires Docker and building the rolter images, so it lives in CI (dispatch/nightly), not the fast PR path.

Disaggregated prefill/decode routing belongs to the engine, not the gateway

Status: Accepted · Date: 11 Aug 2026 · Issues: #853 Relates: ADR-0014 (protocol translation boundary), ADR-0007 (cache-aware balancing)

Context

#853 carried three unclaimed ideas from llm-d. The second — routing by inference phase to specialised pools — was flagged as “the largest of the three and the one most coupled to how the fleet is deployed”, with the note that it may not fit rolter’s position in the stack and that the useful output in that case is a written “no, because”. This is that note.

Disaggregated serving splits inference in two. Prefill processes the prompt, is compute-bound, and produces a KV cache. Decode generates tokens, is memory-bandwidth-bound, and consumes that KV cache. Running them on separately sized pools improves utilisation, because the two phases stress different resources and otherwise contend on the same accelerator.

Making that work requires, per request: selecting a prefill worker, running prefill there, transferring the resulting KV cache to a decode worker, and then streaming generated tokens from the decode worker.

What rolter is

ADR-0014 fixed the boundary: rolter-gateway owns authentication, tenancy, target selection, tracing, metrics and accounting; rolter-proxy owns the wire protocol. rolter forwards one client HTTP request to one upstream and streams the response back. Its routing inputs come from gateway-owned configuration, the client request, and bounded telemetry adapters; it does not participate in an engine’s multi-stage request lifecycle.

Options considered

Option 1 — Implement P/D disaggregation in rolter

The gateway selects a prefill target and a decode target, drives both, and coordinates the KV handoff between them.

Option 2 — Treat a disaggregated fleet as one upstream

The engine-side coordinator owns phase routing and the KV transfer. rolter balances across the fleet’s entry points exactly as it does for any other pool, and does not know a request was split.

Option 3 — Hybrid: rolter routes phases, engine transfers KV

rolter picks both workers and issues two calls, but the KV cache moves over the engine’s own sidechannel rather than through rolter.

Comparison

OptionProsCons
1. Implement in rolterFull control over phase placement; phase-aware scoring composes with existing scorersRequires carrying KV tensors over an engine-specific connector and transport such as NIXL/UCX; the data plane would hold per-request state across a handoff; couples rolter to one engine’s internals and version cadence; duplicates work vLLM and llm-d already do
2. One upstreamPreserves the ADR-0014 boundary; works today with no code; every engine’s own disaggregation implementation is usable, including future onesrolter cannot influence phase placement, so it cannot improve on the engine’s own decisions
3. HybridAvoids moving KV through rolter; an engine-specific HTTP extension can coordinate a connector-managed transferStill needs rolter to know which workers are connector-compatible, emit version-specific control fields and metadata, issue two upstream calls, and keep per-request state across them; a mid-request failure has no clean recovery

Decision

Option 2. rolter will not implement disaggregated prefill/decode routing. A P/D-disaggregated deployment is a single upstream fleet from rolter’s point of view, and its own coordinator owns phase selection and the KV handoff.

Rationale

The KV handoff is the central mechanism. The tensors do not travel in an OpenAI or Anthropic response; engines move them through connector-selected transports such as NIXL/UCX. A gateway that carried the tensors itself would therefore have to join that engine-specific data path.

Coordination can still be initiated over HTTP. For example, vLLM’s experimental disaggregated-prefill API accepts kv_transfer_params on separate prefill and decode requests while its connector moves the KV data out of band. That makes option 3 technically possible, but does not create a stable, engine-independent contract: rolter would still own engine-specific fields, compatible-worker topology, two-call orchestration and state across the handoff. That is precisely the version coupling and lifecycle ownership ADR-0014 was written to prevent.

It would also be redundant. vLLM ships disaggregated serving with a connector API, and llm-d’s router is co-designed with its engine and scheduler — it can assume things about worker topology and KV placement that a general-purpose gateway in front of heterogeneous providers cannot. rolter’s value is that it sits in front of many engines and hosted APIs; a feature that only works for one engine, at one version, with one transport configured, is not a good trade against that.

The distinguishing question is: can rolter consume the input through a stable, engine-independent contract without joining the engine’s request lifecycle? The answer is yes for prefix-cache affinity, queue depth, KV-event residency, LMCache occupancy and LoRA-adapter affinity, which fit the bounded scorer and telemetry interfaces behind ADR-0007 and ADR-0021. P/D placement does not: the placement decision and transfer metadata are part of one engine-specific, multi-call lifecycle.

Consequences

Benefits:

  • the ADR-0014 boundary is preserved: no engine-specific KV connector or phase-control extension in the data plane, and no per-request state held across an upstream handoff;
  • disaggregated fleets are usable with rolter today, with no code, for any engine that implements disaggregation — including engines that do not exist yet;
  • rolter does not inherit the failure modes of a mid-request handoff, which has no clean recovery once prefill has completed and decode is unreachable.

Drawbacks and risks:

  • rolter cannot improve on the engine’s phase placement, and cannot report on it beyond whatever the fleet exposes over its metrics endpoint;
  • if a stable, engine-independent phase-placement contract emerges on the OpenAI or Anthropic surface, this decision should be revisited, because the portability and lifecycle objections would no longer hold.

System impact:

None. This is a decision not to build something; no code changes.

  • ADR-0007 — Approximate cache-aware balancing behind a pluggable trait
  • ADR-0014 — Extensible API protocol translation
  • ADR-0021 — External cache telemetry for routing

Open questions

  • Should rolter document a recommended topology for putting a disaggregated fleet behind it (which entry point to target, and how to configure health checks against a coordinator rather than a worker)? That is documentation rather than a routing feature, and would be useful independently of this decision.

An explicit per-provider opt-out from a hosted kind’s host pin

Status: Accepted · Date: 2 Sep 2026 · Issues: #925, #924 Relates: ADR-0022 (config-vs-DB entity tiering)

Context

ProviderKind::Openrouter pinned api_base to one literal string:

#![allow(unused)]
fn main() {
if provider.api_base.trim_end_matches('/') != "https://openrouter.ai/api/v1" {
}

Paired with the rule that an openrouter provider must source its key from api_key_env, the pin closes a real exfiltration path. A hosted kind names one operator, and the credential belongs to that operator. Without the pin, a single edited line in a config file — or a row in the providers table — would send a live OpenRouter key to whatever host the edit named, over TLS, with no error and no signal that anything changed.

The pin also rules out three things operators legitimately do:

  • put a corporate egress proxy or internal gateway in front of the vendor
  • point at an endpoint that re-implements the vendor’s surface
  • test the dialect at all — which is how this surfaced. The #924 dogfooding fleet could not declare kind = "openrouter" against a local fake, so it declared the endpoint openai_compatible instead and the OpenRouter adapter went unexercised end to end.

So this is a decision about how an operator opts out, not a bug fix. The question the options have to answer is what stops the opt-out from quietly becoming the shape everyone copies off a blog post.

Options considered

  1. Allow loopback and private-range hosts unconditionally. Enough to test with, no help for a corporate proxy, and it makes the rule depend on where rolter happens to run rather than on what the operator intended.
  2. An explicit allow_custom_api_base = true on the provider. The safe default is unchanged; the unsafe case is one reviewable line in the configuration, in the same diff as the base URL it enables.
  3. Drop the pin and rely on the api_key_env rule alone. That rule keeps the secret out of config files and database rows, but it does nothing about where the secret is sent — which is the exfiltration path the pin closes.

Decision

Option 2. ProviderConfig gains allow_custom_api_base: bool, defaulting to false:

  • Unset, every hosted kind keeps the exact validation it has today.
  • Set, the host pin is skipped for that provider and nothing else is relaxed. In particular the api_key_env requirement still applies, so the credential cannot be inlined next to the redirected base URL.
  • The rejection message names the opt-out, so the operator who hits the pin learns the supported way past it instead of inventing one.
  • rolter check reports every provider that sets it as a warning, and rolter check --strict fails on it. The opt-out is legitimate; being invisible is what would make it dangerous.

Only file-configured providers can set it today. Providers stored in Postgres keep the safe default: the column, the CRUD surface and the dashboard control are a separate change, tracked as its own issue.

Consequences

  • The dogfooding fleet declares kind = "openrouter" against 127.0.0.1:18002, so the OpenRouter dialect is exercised by a running fleet for the first time.
  • An operator behind an egress proxy can use the hosted kind rather than downgrading to openai_compatible and losing the dialect’s behaviour.
  • A deployment that does not set the flag is bit-for-bit unaffected.
  • The flag is per provider, not global, so enabling it for a test endpoint cannot silently unpin a production one beside it.
  • A stored provider cannot opt out until the DB surface lands, which is a visible asymmetry between the two configuration tiers for as long as it stands.

A served OpenAPI document for the control-plane API

Status: Accepted · Date: 8 Sep 2026 · Issues: #1040, #421, #422, #851 Relates: ADR-0027 (end-to-end test harness)

Context

The gateway describes itself. crates/rolter-gateway/src/openapi.rs hand-builds an OpenAPI 3.1 document, serves it at GET /openapi.json, and renders it with a Scalar bundle embedded in the binary at GET /docs. A client can point a generator at a running gateway and get the OpenAI/Anthropic-compatible surface.

The control plane does not. Its management API — roughly 160 paths and 240 operations across tenancy, users, RBAC, providers, routes, virtual keys, budgets, guardrails, MCP, SSO, SCIM, alerting and the deployment settings singletons — exists only as an Axum router assembled in crates/rolter-control/src/lib.rs from about thirty router() functions. The only machine-readable description of it is the dashboard’s own ui/src/lib/api.ts, which is a client, not a contract.

Three SDKs are planned: Rust (#421), Python (#422) and JS/TS (#851). Without a schema each of them hand-writes the same 240 operations, and each of them re-hand-writes them every time an endpoint is added. Nothing tells an SDK author that a new endpoint exists, so the three clients drift from the server and from each other, independently and silently.

There is a second, sharper problem: even with a document, a hand-maintained one goes stale the first time somebody adds an endpoint and forgets. A schema that is 95% right is worse than none, because a generated SDK reports the missing 5% as “not part of the API” rather than as “not documented yet”.

Options considered

  1. Accept hand-written SDK surfaces. Cheapest today. It moves the drift problem into three repositories instead of solving it once, and puts the cost on whoever adds the 241st endpoint, three times over.
  2. Derive the document from the handlers with utoipa. Attractive because the annotation lives next to the code it describes. It means adding a proc-macro dependency and its whole derive graph to a crate that currently has neither, annotating every handler and every request/response type in one change, and accepting that the derived schema describes rolter’s Rust types rather than its wire contract — the two diverge wherever a handler flattens, renames or hides a field, which several of them do (VirtualKey.key_hash, User.password_hash, CreatedVirtualKey’s flattened plaintext).
  3. Hand-build the document, and make a test enforce completeness. The gateway’s existing pattern, plus the missing half: a coverage gate that fails the build when a mounted route has no row in the document.

Decision

Serve it, option 3.

crates/rolter-control/src/openapi.rs hand-builds an OpenAPI 3.1 document from one table of operations and serves it at GET /openapi.json, with the same embedded-Scalar reference at GET /docs the gateway already ships. This matches the gateway’s approach rather than introducing a second OpenAPI toolchain into the workspace, and it keeps the document describing the wire contract rather than the Rust types behind it.

Completeness is enforced, not hoped for. every_registered_route_is_documented walks every route registration under crates/rolter-control/src/ — reading the source, so it cannot drift from what is actually mounted — and fails naming any (path, method) with no row in the table. every_documented_route_is_registered runs the check in reverse, so a deleted endpoint cannot linger in the schema. This is the same shape of drift guard as the_matrix_lists_every_capability_exactly_once in rbac_matrix.rs and the_gateway_never_uses_a_poisonable_mutex in the gateway’s lock_discipline test: a list that must not silently fall behind the code gets a test that compares it to the code.

Body schemas are deliberately uneven, and the gate is what makes that safe. Endpoints whose handlers already have a stable serde shape — tenancy, users and memberships, providers and provider groups, routes and route targets, virtual keys, budgets, rate limits and model prices — are typed against 47 entries in components/schemas. Surfaces whose payload is genuinely dynamic — settings singletons, guardrail configs, SCIM envelopes, analytics rows — are documented as open objects. Every one of them still appears with its path, method, tag, path parameters and error response, so a generated SDK has the call even where it does not yet have the struct, and tightening one later is a local change rather than a discovery problem.

Consequences

  • The three SDK issues can generate from a schema instead of transcribing a router, and a fourth client gets the same starting point for free.
  • Adding a control-plane endpoint now costs one row in operations(). Forgetting it is a test failure that names the route, not a silent gap — which is the whole point, and the reason a hand-built document is defensible here.
  • rolter-control gains the scalar_api_reference dependency the gateway already carries. The bundle is embedded, so /docs still works air-gapped.
  • The document describes the surface the binary can serve, not the subset a given process happens to have mounted: the CRUD routes only mount with a postgres pool, but the schema lists them unconditionally. A schema that changed shape with deployment configuration would be useless to generate from.
  • The coverage gate reads source text rather than introspecting the router, because Axum exposes no way to enumerate registered routes. It skips comment lines and #[cfg(test)] items, and a route registered through anything other than a literal path would escape it. That is a known limit, and the crate registers every route as a literal today.
  • Loose bodies are a debt that is now visible and enumerable rather than invisible: every operation carrying an open object is one grep away.

One model for turning a subsystem off

Status: Proposed · Date: 8 Sep 2026 · Issues: #1073, #535 Relates: ADR-0030 (served OpenAPI document)

Context

rolter ships a growing set of optional subsystems, and an operator who wants to turn one off has to find out, per subsystem, which of several unrelated mechanisms applies. #1073 describes this as three mechanisms coexisting without written boundaries. The survey below found eight.

What exists today

#MechanismWhereSubsystems
1Deployment-wide DB-backed flagFeatureFlagsConfig, config.rs:171; applied by apply_feature_flags(), config.rs:201response_cache, cache_aware_routing, circuit_breaker, active_health_checks, complexity_routing, guardrails
2Per-subsystem enabled: bool in configthe subsystem’s own config structcache, health, breaker, guardrails, guardrail webhook, PII sanitizer, prompt templates, payload capture
3Option<T> presenceRouteConfig.semantic, config.rs:1868semantic cache (per route)
4Empty collectionPluginsConfig.instances, GatewayConfig.mcp_serversplugins, MCP servers
5Numeric sentinelRealtimeConfig (0 = no cap), usage_recording.sample_rate = 0realtime caps, request-log rows
6Per-object enabled rowprovider, route, guardrail rule, MCP server, SSO provider tablesthose objects individually
7Compile-time cargo featurerolter-control{ldap,postgres}, rolter-core{otlp}, rolter-store{postgres}, rolter{postgres}LDAP, postgres store, OTLP export
8Implicit infrastructure capabilityunavailable_flags(), feature_flags.rs:44; ui_events “inert anyway without clickhouse_url”response cache (needs Redis), cache-aware routing (needs a provider publishing KV/LMCache signals), UI events (needs ClickHouse)

Mechanisms 1 and 2 are not alternatives — 1 drives 2. apply_feature_flags() writes the deployment flag into the subsystem’s own enabled field, and for two flags it does something else entirely: cache_aware_routing off rewrites every cache-aware route’s strategy to PowerOfTwo, and complexity_routing off removes a route param. So “off” already means three different things in one function: clear a boolean, substitute a fallback behaviour, and delete configuration.

The three gaps

Coverage. Ten subsystems have no deployment-wide off switch: MCP gateway, PII sanitizer, guardrail webhooks, semantic cache, budgets, rate limits, plugins, payload capture, realtime, analytics. Several have a per-object enabled (mechanism 6), which is not the same thing — an operator disabling a subsystem should not have to edit every row.

The dashboard does not react. ui/src/lib/nav.tsx:167 and ui/src/App.tsx:131 reference feature flags exactly once each, and only to register the FeatureFlags screen itself. No other nav entry or route consults flag state, so disabling a subsystem leaves its nav entry and screen in place, reachable, backed by an API that now returns nothing useful.

No shared definition of “off”. Nothing says whether a disabled subsystem should reject, bypass, 404, or merely hide. Each subsystem answers independently, which is why mechanisms 3, 4 and 5 exist at all: each was a locally reasonable choice made without a rule to follow.

Decision

A. Two layers, and only two

  • Capability (compile-time or infrastructure): can this deployment run the subsystem at all? Sources are cargo features (7) and infrastructure presence (8). Not operator-settable at runtime; surfaced read-only.
  • Enablement (deployment-wide, DB-backed, hot-reloadable): should this deployment run it? One boolean per subsystem in FeatureFlagsConfig.

Per-object enabled rows (6) stay, and are explicitly not part of this model: they answer “should this provider be used”, not “does this deployment run provider health checks”. The ADR’s rule is that a subsystem must never be disabled only by emptying its collection or unsetting an Option — mechanisms 3, 4 and 5 are demoted from enablement mechanisms to configuration details, and each gains a real flag.

A cargo feature can never be a dashboard switch. That is the whole reason the capability layer is separate, and the reason unavailable_flags() already exists: it is the precedent for “the switch exists, but this deployment cannot honour it”, and it should be extended rather than duplicated.

B. Off semantics by class

ClassSubsystems“Off” means
Request-path filterPII sanitizer, guardrails, guardrail webhook, plugins, prompt templatesBypass silently. The request proceeds unmodified. A filter that fails closed when disabled would make the switch an outage.
Optimisationresponse cache, semantic cache, cache-aware routing, complexity routing, circuit breaker, active health checksBypass, fall back to the simple path. Already the behaviour apply_feature_flags() implements for cache-aware routing.
Gateway surfaceMCP gateway, realtime501 Not Implemented with a body naming the flag. Not 404: the path exists in this build and a 404 is indistinguishable from a typo, which turns an operator’s deliberate choice into a debugging session.
Enforcementbudgets, rate limitsBypass, and say so. Disabling enforcement is a security-relevant act, so it is audited on write and reported by rolter check.
Recordingusage recording, payload capture, analytics, UI eventsDrop at ingest. The hot path must not pay for a disabled recorder.
UI-only surfaceany screen whose whole subsystem is offHide the nav entry and route.

The rule behind the table: a disabled subsystem never changes an answer the client already had a right to. Filters and optimisations bypass; surfaces that would otherwise lie say 501.

C. Propagation

Flags the data plane reads travel the existing path: control plane → /internal/snapshot → ArcSwap in the gateway. Per AGENTS.md any new flag column needs a bump_config_version() trigger migration, or the gateway serves stale config forever.

The dashboard learns state from the existing GET /api/v1/feature-flags, extended with the capability layer, rather than a second bootstrap payload — the response already carries unavailable, which is the same shape.

D. Config-file reconciliation

rolter.toml’s [feature_flags] is the boot default; the DB row wins once the control plane has one, matching how every other config table already behaves. A store-less gateway uses the file alone. rolter check warns when the file disagrees with the DB, because silently ignoring a file an operator edited is how a deployment ends up with a flag nobody can explain.

E. Naming

<area>.<subsystem>, grouped for the dashboard by area: routing, safety, caching, observability, integrations. Flags are permanent operational toggles, not release flags — so no flag is ever added “temporarily”, and removing a subsystem removes its flag in the same change.

Consequences

  • Every optional subsystem gets one switch in one place, and the dashboard can finally hide what is off.
  • Default-on is guaranteed by construction: every new flag defaults true via #[serde(default = "default_true")], so an upgrade that adds flags cannot change behaviour. This wants a test asserting FeatureFlagsConfig::default() is all-true, so the guarantee cannot rot as flags are added.
  • Ten subsystems need a flag, a migration with a bump_config_version() trigger, snapshot plumbing, an off-semantic implementation and a dashboard switch. That is the follow-up work, sized per subsystem rather than as one change.
  • Mechanisms 3, 4 and 5 do not disappear; they stop being the way to disable something. Existing behaviour is unchanged — an empty plugin list is still inert — but it is no longer the documented control.
  • The 501 choice for gateway surfaces is a wire-visible decision. It is the one part of this ADR a client can observe, so it belongs in the OpenAPI document (ADR-0030) when implemented.

Rejected

  • One enabled per subsystem config and nothing else. Simplest, but it is mechanism 2 as it stands, and it cannot express the capability layer — an operator flipping response_cache on with no Redis gets silence rather than “this deployment cannot run that”.
  • A generic features: HashMap<String, bool>. Removes the compile-time guarantee that a flag name is real, and makes the dashboard’s grouping and the default-on test impossible to enforce.
  • Folding flags into the SPA bootstrap payload. /api/v1/feature-flags already exists, already carries unavailable, and is already superadmin-gated for writes while readable for the nav gating; a second payload is a second thing to keep in step.

What 1.0.0 guarantees, surface by surface

Status: Accepted · Date: 9 Sep 2026 · Issues: #922 Relates: ADR-0011 (API surface v1), ADR-0012 (Conventional Commits), ADR-0017 (provider/model addressing), ADR-0030 (control-plane OpenAPI document)

Context

1.0.0 is not a quality claim. It is a compatibility claim, and semver assigns it a meaning whether or not anyone writes one down. Today the workspace is 0.1.0, where semver says nothing is stable, so the question has never had to be answered. Cutting 1.0 answers it by default — badly — unless it is chosen.

rolter has four externally visible surfaces plus one that is visible only because of how it is distributed, and they do not have the same answer:

SurfaceWho consumes itCan rolter control its shape?
/v1/* gatewayOpenAI and Anthropic SDKs, any HTTP clientNo — it mirrors two vendors
/api/v1/* control APIthe dashboard, and SDKs after #851/#421/#422Yes, entirely
rolter.toml and env varsoperators, Helm values, compose filesYes
the Postgres schemarolter itself; operators’ backupsYes
eight crates on crates.ioanyone who runs cargo addYes

Two things already exist and constrain the answer rather than being free choices:

  • docs/dev-docs/development/api-stability.md (#1217) already scoped cargo-semver-checks to four crates against the previous release tag, and it ends by planning to make that job blocking at 1.0 and to revisit the guarded list. That plan is a promise made in advance of this decision, and this ADR is what supersedes it.
  • Stability markers (#1385) ship a per-subsystem experimental marker whose entire purpose is to be the documented exemption from whatever 1.0 promises. This ADR uses it and does not invent a second exemption route.

The crates are the surface most likely to be answered by accident. Publishing rolter-core 1.0.0 states that its Rust API is stable, because that is what the number means on crates.io, and two mechanisms would then start enforcing a promise nobody made: the semver-checks job, which api-stability.md plans to promote at 1.0, and release-plz, whose semver_check defaults to true for library packages and would propose a major bump of the whole workspace — every binary, the wheel, the Docker tag — because a shared helper changed shape. #1041 and #1042 are already filed, explicitly behaviour-preserving refactors of exactly that kind.

Decision

1. The Rust crates are internal. There is no stable Rust API at 1.0

rolter’s product is two binaries and a dashboard. The crates exist so those binaries can share code; they are on crates.io because cargo install rolter requires every transitive dependency to be published, not because anyone asked to build against them.

At 1.0 and for the whole of 1.x, no rolter crate offers a stable Rust API. Any public item in any crate may change or disappear in any release. The version number on crates.io is the product’s version, and it says nothing about the Rust surface.

Because a version number cannot carry that caveat, three places say it in words:

  • the description of every published library crate ends with — internal crate, no stable rust api, which is what crates.io search, cargo add and the docs.rs sidebar show;
  • the crate-level //! docs say it in the first screen a docs.rs visitor reads;
  • the README says it under Repository layout, next to the crate list.

And two mechanisms are made to agree with it rather than contradict it:

  • release-plz gets semver_check = false at the workspace level, so a Rust API diff can never drive rolter’s product version.
  • The semver-checks job stays advisory permanently — continue-on-error, never in ci-ok’s needs:. It is retained as a review signal on the four crates in GUARDED, not as a gate, and api-stability.md is rewritten to say so rather than to plan a promotion that this decision withdraws.

Rejected: freeze the Rust API at 1.0. It buys nothing for the users rolter has and costs every internal refactor, starting with two already filed. A promise whose first act is to block behaviour-preserving cleanups is a promise made to nobody.

Rejected: stop publishing the library crates. Cargo requires the full dependency graph to be on crates.io for cargo install rolter to resolve, so publish = false on rolter-core removes the installation path that ADR-0010 picked. The crates have to be published; the question is only what publishing them says.

Rejected: version the libraries separately, keeping them at 0.x while the binaries go 1.x. This is the semver-correct way to say “no stable Rust API”, and it is the closest call here. It loses on operational cost: the shared workspace version is what lets one package cut the single v{version} tag, what pyproject.toml reads for the wheel, and what the /api/v1/version endpoint reports. Splitting it means per-crate bump decisions, diverging changelogs, and an operator reading rolter-core 0.9.3 inside rolter 1.4.0 and reasonably concluding the product ships a pre-release component. The disclaimer belongs where a reader actually looks — the description and the docs.rs landing page — and nushell publishes forty nu-* crates on exactly this model.

2. /v1/* guarantees the dialect, not a schema

rolter cannot promise a frozen /v1/*. The schema is OpenAI’s and Anthropic’s, they change it without asking, and the v1 in the path is theirs too — client SDKs hardcode it, so rolter has no /v2/ escape hatch on this surface at all. A promise to freeze it would be broken by someone else’s release notes.

What 1.0 promises instead:

  • Fidelity. A request that the upstream provider’s own API accepts is accepted by rolter and forwarded, and the response is shaped the way that provider shapes it. Unknown body fields travel; rolter is a pipe, and that is what makes this promise keepable.
  • Tracking, not freezing. When OpenAI or Anthropic changes a dialect, rolter follows. A change that exists only to restore fidelity with an upstream is not a rolter breaking change, does not carry BREAKING CHANGE:, and is called out in the release notes as an upstream dialect change. Refusing to follow would break every client that also talks to the real provider, which is the opposite of the compatibility rolter sells.
  • The rolter-owned parts of /v1/* are promised like the control API. These are the parts no vendor owns: the virtual-key auth scheme (Authorization: Bearer and x-api-key), the model-addressing grammar — route names and provider-slug/model from ADR-0017 — the shape of rolter’s own error envelope, rolter-specific request and response headers, and the shape of GET /v1/models. These follow the /api/v1/* rules below, including the deprecation window.

The practical read for an operator: if the field you depend on is in OpenAI’s or Anthropic’s documentation, your guarantee is that rolter keeps up with them. If it is in rolter’s documentation and nowhere else, your guarantee is rolter’s.

3. /api/v1/* is additive within v1, with a two-minor-release, 90-day removal window

This is the surface rolter fully controls, and after #851/#421/#422 it is the one SDKs pin against. It gets the real promise.

Within the v1 prefix, for the whole of 1.x:

  • Additive changes may ship in any release. New endpoints, new optional request fields, new response fields, new values in an enum the OpenAPI document marks as open. A client that ignores unknown fields is never broken by these, and clients are expected to ignore unknown fields.
  • Removing or incompatibly changing anything published in the control-plane OpenAPI document requires a deprecation period of at least two minor releases and at least 90 days, whichever ends later. The clock starts at the release that publishes the deprecation. The deprecation is announced in the release notes, marked deprecated: true on the operation or schema in the control-plane OpenAPI document, and served with Deprecation and Sunset response headers (RFC 9745, RFC 8594) so a client can find out without reading a changelog. After the window, removal ships in a normal minor release.
  • /api/v2/ is reserved for a wholesale reshaping, served alongside v1, not spent on a single removed field.

Why two minors and 90 days rather than one number. A pure release count is worthless at an unfixed cadence — two minors can be a fortnight. A pure time period can pass with no release at all, so nobody ever sees the notice. Both bounds together mean the deprecation appears in at least two sets of release notes and survives a quarterly upgrade cycle, so a team that upgrades once a quarter meets the warning at least once before it bites. Longer windows (180 days, a year) sound generous and in practice freeze the API, which pushes work toward a premature v2 — the outcome the window exists to avoid.

One escape: a change required to fix a security vulnerability may shorten the window. It is still announced, and the advisory says why.

That the dashboard ships in lockstep with the control plane is not a reason to skip the window. The dashboard is not the only client any more.

4. Config files: a 1.0 rolter.toml is read by every 1.x

A configuration file and an environment that a 1.0.0 binary accepts are accepted by every later 1.x binary, with the same meaning. New keys are optional and default to the previous behaviour.

Removing a key, or changing what an existing key means, follows the same window as /api/v1/*: two minor releases and 90 days, announced in the release notes, with the deprecated key still honoured and rolter check warning on it for the whole window. ADR-0022’s deprecated top-level [[providers]] / [[routes]] / [[provider_groups]] arrays are already on this footing.

The mechanical basis for the forward half is that unknown keys are ignored rather than rejected, so an older 1.x binary starts against a newer file. It starts having silently ignored the newer keys, which is a real trap; rolter check is where that is surfaced, and widening it to report unrecognised keys is worth doing on its own.

5. The database schema moves forward only. There is no downgrade

Migrations are append-only (#724) and there are no down scripts. 1.0 does not add any. The supported upgrade is: take a backup, start the newer binary, let it migrate. The supported rollback is restore the backup — see Backup and restore. Pointing an older binary at a database a newer one has migrated is not a supported configuration.

Two things soften that in practice and neither is a promise:

  • The data plane never reads Postgres. The gateway consumes /internal/snapshot, so schema compatibility is a question between the control plane and its own database, and the two ship together.
  • Because migrations only add, an older 1.x control plane usually keeps running against a schema a newer 1.x has migrated, which is what makes a rolling upgrade survivable. A migration that changes the meaning of an existing column breaks this, and such a migration is a breaking change under rule 6 and is announced as one.

6. What BREAKING CHANGE: means after 1.0

Before 1.0 the trailer meant “something moved”, including a Rust API. That reading dies here, because release-plz turns ! or a BREAKING CHANGE: footer into a major bump of the shared workspace version — one trailer takes the whole product to 2.0.0.

After 1.0:

  • Use it for a change to a promised surface that ships without a completed deprecation window. That is the definition. It requires a written migration note in the release, and it takes rolter to the next major.
  • Do not use it for a removal that completed its window. The announcement and the window are the compatibility mechanism; that is the whole value of promising a window instead of promising permanence. The removal ships as a normal feat/refactor whose release notes name what went.
  • Do not use it for a Rust API change in any crate. They are internal (rule 1).
  • Do not use it for an upstream dialect change rolter is tracking (rule 2).
  • Do not use it for a subsystem carrying the experimental marker. That marker is the documented exemption, and it already says the subsystem may change shape or be removed in a minor release. Which subsystems those are lives in SUBSYSTEMS and in Stability markers; this ADR neither restates that table nor adds a second exemption mechanism beside it.

The rule of thumb the trailer encodes: if the change could have been deprecated first, it must be, and then it is not breaking. BREAKING CHANGE: is for the changes that could not.

Consequences

  • Somebody who runs cargo add rolter-core at 1.0 is told, in the place cargo shows them, that they are on their own. Nothing else changes for them, and #1041 and #1042 proceed.
  • The semver-checks job never becomes a merge gate, and api-stability.md’s “At 1.0” section is rewritten to match. A red advisory run means a shared crate’s API moved — useful review information, not a failure.
  • release-plz stops running cargo-semver-checks at all, so the product version is driven only by commit types, which is what ADR-0012 always intended.
  • /api/v1/* acquires real obligations that do not exist today: deprecated: true in the OpenAPI document, Deprecation/Sunset headers, and a deprecations section in the release notes. None of that is implemented yet; it is filed as follow-up work and is a prerequisite for the first deprecation, not for 1.0 itself.
  • The experimental marker becomes load-bearing. A subsystem left marked is exempt from all of the above, so removing a marker is now the expensive act and belongs in the PR that closes the gap the note describes — exactly as Stability markers already requires.
  • The honest cost of rule 2 is that an operator’s /v1/* compatibility is partly held by OpenAI and Anthropic. rolter states that plainly rather than implying a guarantee it would have to break the first time a vendor ships a change.

The merge queue is how master stays green

Status: Accepted (repository side shipped; the branch-protection setting is pending an admin) · Date: 18 Sep 2026 · Issues: #1318, #1150 Relates: ADR-0030 (served OpenAPI document), merge protection on master, the ci-ok gate

Context

ci-ok is evaluated per pull request, against whatever base that branch was cut from. Branch protection does not require a branch to be up to date, so GitHub will merge a branch that is arbitrarily behind master without re-running anything. Two pull requests that are each green can therefore combine into a red master, and nothing sees it until master’s own push run.

This is not a hypothetical. It has now happened twice in the shape the repository was warned about:

  • c8b6d0dc — #1132 added a field to ProviderConfig, #1138 added a test that built one with an exhaustive struct literal. Neither branch contained the other (#1150).
  • The OpenAPI completeness gate — #1310 added openapi::tests::every_registered_route_is_documented, #1311 mounted GET /api/v1/config/export, #1299 mounted PUT /api/v1/sso-providers/{id}. All three green, all three blind to each other, fixed forward in #1316 (#1318).

The exposure is structural rather than accidental. This repository deliberately carries several tests that assert a global property of the tree — the OpenAPI operations table, the_matrix_lists_every_capability_exactly_once in rbac_matrix.rs, the i18n catalog checks, SEALED_COLUMNS in kek_audit.rs, the migration-number sequence. Every one of them is a guard that two independent changes can each satisfy alone and jointly violate. The merge rate makes it worse: most work here is done by parallel agent sessions, several merges a day, and two branches have already claimed migration 0071_ in the same week.

merge-protection.md recorded the decision to do nothing about this for now, and set an explicit condition for revisiting it: if a semantic conflict reaches master twice more, turn the merge queue on. The OpenAPI break is the second, and it was the second while the page was being written. The condition is met.

Decision

Enable GitHub’s merge queue on master, and keep required_status_checks.strict = false.

ci.yml gains a merge_group: trigger, so the queue builds a synthetic branch holding master plus every entry ahead of this one and runs the same ci-ok against that tree. A combination that fails is dequeued instead of landing.

ci-ok remains the single required status check. The queue asks for the repository’s required checks by name, so there is nothing new to name and no second gate to keep in sync — the invariant that ci-gating.md already protects is what makes this a one-trigger change.

Why not “require branches to be up to date”

It is one checkbox, which is its only advantage. It is strictly worse than the queue on this repository:

up-to-date requirementmerge queue
Who rebasesevery author, by hand, every time anything landsnobody
Gate runs to land n ready PRsn — one per PR, each invalidated by the next mergeone per batch, bounded by the batch size
Behaviour when several land in a rowlast-merge-wins thrash; a PR that finishes rebasing is already stale againentries re-test only when something ahead of them fails
Correctnessthe gate ran on a tree containing current master at some point before the merge button was pressedthe gate ran on the exact tree that will exist
Failure lands onwhoever was slowestthe PR that actually broke it

The thrash is the decisive part. With several agent worktrees and dependabot in flight, master moves faster than a gate run takes, so an up-to-date requirement can be unsatisfiable in practice: the branch goes stale while its re-run is still going. The queue does the same serialisation, but it does it once, on the server, without anyone pushing anything.

Why not just watch master’s push run

We already do, and it stays. ci.yml triggers on push: [master] with a per-run concurrency group, so every merge commit keeps a completed gate run (#1328). That is detection, not prevention: the break still reaches master, and the person who finds it is whoever pushes next and gets a red ci-ok nothing in their diff explains. It is a backstop for the case the queue cannot cover — a merge landed by an admin bypass, or a queue misconfiguration — and it costs nothing to keep.

Consequences

Merging becomes asynchronous. gh pr merge on a queue-protected branch enqueues rather than merges; the PR shows “queued” and lands minutes later when its merge-group run is green. An agent that merges and immediately reports done is now reporting on an intent rather than an outcome.

Latency goes up, throughput does not go down. One extra full gate run (quality + codeql, the expensive pair) per batch rather than per PR. Batching is what keeps the cost bounded: five ready PRs cost one run, not five. merge_group runs get a per-run concurrency group for the same reason push runs do — a cancelled run is not a passing check, so cancelling a merge-group run dequeues everything it was carrying.

Stacked PRs are unaffected, and that is worth stating explicitly because AGENTS.md documents a second merge path for them. A stacked child targets its parent’s branch, which is not protected and has no queue, so children merge exactly as they do today. Only the bottom PR of a stack targets master, and that one goes through the queue like anything else. The PUT .../pulls/{n}/merge-async requirement is a property of a PR being part of a stack, not of the base branch’s protection, so it continues to apply where it applied before. What does change: a stack’s bottom PR no longer merges instantly, so the children retarget onto master a few minutes later than they used to.

The fast path is fenced off from the queue. The title-edit fast path in ci.yml lets ci-ok report green without re-running the gate, on the strength of an earlier run on the same head sha. A merge-group tree has no earlier run by construction — that is the entire point of it — so the guard conditions are now scoped to github.event_name == 'pull_request' rather than to github.event.action != 'edited' alone. They were already equivalent, since no other event carries action: edited; the change is that they cannot stop being equivalent.

The agent-session-url checks follow the queue. A squash merge writes the PR body into the commit message, and the queue is what performs the merge, so both halves of that check run on merge_group: the body via a new --pr-for-queue-ref mode that resolves the PR from the number embedded in gh-readonly-queue/<base>/pr-<n>-<sha>, and the commits via the payload’s own base_sha..head_sha, which is also the only place anything reads the commit messages of PRs batched ahead of this one. quality.yml still takes no secrets; both of these live in ci.yml, on the side of the line that may hold a token (#734).

Enabling the queue is a repository setting. No pull request can make it, so the workflow side ships first and is inert until someone with admin turns the queue on. The exact commands are in merge-protection.md.

Rejected

  • strict = true — covered above; worse on every axis except the effort of turning it on.
  • A scheduled quality.yml run on master — shortens the time a break goes unnoticed, does not prevent it, and the existing push run already does the same job sooner and for free.
  • Making the queue the only merge path by forbidding admin bypass — a separate decision with its own cost, tracked at #1158.

CI is sized to the 20-slot runner pool

Status: Accepted (rolling out as the PRs under #2025) · Date: 29 Sep 2026 · Issues: #2025 Relates: ADR-0033 (merge queue), ADR-0032 (1.0 guarantees, which keep semver-checks advisory), the ci-ok gate, merge protection, packaging and releases

Context

The rolter-ai organisation is on GitHub’s Free plan and rolter is its only repository, so every workflow run draws on one pool of 20 concurrent standard jobs, 5 of them macOS (Actions limits). CI had grown to ask for far more than that. One push to a pull request started 36 jobs in the same second, 28 from quality.yml and 8 from ci.yml, so 16 of them queued even when the organisation was otherwise idle.

The pool was saturated. The audit behind #2025 measured job concurrency across 474 runs from the Actions API, second by second from each job’s real start and end times. Org-wide running jobs peaked at exactly 20, and while anything was queued, 17 to 20 jobs were running in 92% of seconds on 2026-09-28 and in 98% of seconds during the burst on the night of 09-26/27.

Queueing cost master far more than it cost pull requests. Over the 88 successful full-gate ci.yml runs created between 2026-09-26 20:34Z and 2026-09-28 15:55Z:

EventRunsMedian runp90Median time lost to queueing
pull_request559.4 min39.8 min1.5 min
push to master2629.7 min43.8 min21.5 min (max 53.0)
workflow_dispatch (release PR)711.8 min20.9 min5.0 min

A typical PR gate was already close to the no-queue floor of about 8.1 min, which codeql (rust) sets at 488 s. Pull requests suffered in the p90 tail on busy days. The master push gate lost a median 21.5 min, and it is the run a release waits on.

Master pays because every merge fans out. An isolated merge started 33 jobs for the ci.yml push run and 30 for release-plz, 27 of which were verify, a full re-run of quality.yml on the sha the push run was already checking. Then, 2 to 44 min later, release-plz dispatched a 35-job ci.yml run on the release PR. That is a ceiling of about 98 jobs per merge. The measured average over 27 master pushes (09-26 20:34Z to 09-28 16:10Z) was about 57, because release-plz’s concurrency group cancelled 15 of its 27 runs while they were still pending, and over those 27 pushes only 11 release-PR dispatches ran. Even so, post-merge work took 51% of the runner time on 09-28, against 43% for PR gating. verify alone was 13% and the release-PR dispatches 16.7%, and 226 of the 355 slot-minutes those dispatches used went to runs that were later cancelled.

The job list itself was wasteful:

  • 17 jobs did under 20 s of work. They were 45% of all jobs and 4.7% of slot-seconds, and their checks took 0-15 s. The 13 of them in quality.yml had median waits for a runner between 86 and 200 s depending on the job, with one wait of 553 s. The four in ci.yml usually got a runner within 20 s.
  • The helm chart job spent about 200 s building the project’s maturin wheel by accident: uv run --with pyyaml in the repository root installs the project. That one job cost more runner time than the 17 small jobs together.
  • Informational jobs paid for full builds on every push. Coverage took 257 s, compose smoke 326 s (280 s of it a cold Docker release build), and the macOS check held one of the 5 macOS slots.
  • The msrv job never tested 1.82 at all, because rust-toolchain.toml pins stable and outranks the rustup default the job sets (#2026).
  • #1950’s image smoke (published ports) added a second cold Docker build, 308 s in the one run measured, to every quality.yml call (#2037).
  • The Actions cache held 11.53 GB in 18 entries against its 10 GB limit, so master’s Rust caches were evicted and jobs built cold.

The merge queue that ADR-0033 decided on is also not enabled today: mergeQueue(branch: "master") is null and ci.yml has never run on merge_group (#2029). Until it is, the master push run is the only check on the tree that lands, which makes its 21.5 min of queueing more expensive.

Decision

Run fewer, larger jobs, keep ci-ok exactly as branch protection and the release tooling see it, move informational work off the push path, and stop re-running the gate after every merge. A PR push goes from 36 jobs to 11. An isolated merge to master goes from a ceiling of about 98 to 13, or 23 while the release PR carries release:ready.

The layout

JobWorkflowRuns whenBlockingContains
static checksquality.ymlevery callyesgitleaks (working tree first, then branch history), agent session urls in commits, the pr-body step against a fake gh, migrations append-only, typos, taplo fmt, cargo-deny, unused deps, actionlint, zizmor, release handoff wired, board automation retry policy, helm chart (the checker runs under uv run --script), and a report step. 65-90 s
rust lintquality.ymlevery callyesfmt, clippy (default and postgres), cargo doc, cargo hack check --each-feature and the cross-crate feature combination. About 220 s warm
rust buildquality.ymlevery callyes, except semverpackage (publish verify, plus a maturin sdist check of the PyPI packaging) and the gateway smoke build and probe, then three semver-checks steps (install, baseline, check), placed last and each continue-on-error
nextest / doctestsquality.ymlevery callyesunchanged, about 388 s, 30 min job timeout
image smoke (published ports)quality.ymlevery callyesunchanged from #1950, still its own job
ui, storybook, docsquality.ymlevery callyesone pinned bun and one bun install, then every former ui, storybook, docs-formatting and llms.txt check as its own step. About 320 s, 25 min job timeout
coverage (informational)quality.ymlpull_request onlynounchanged llvm-cov, ratchet and lcov artifact
codeql (rust)ci.ymlunchangedyessame name; on a PR with no Rust change it skips extract and analyze inside the job
codeql (actions-js-python)ci.ymlunchangedyesone leg for actions, javascript-typescript and python, which were three
gate-okci.ymlunchangedyesunchanged; the job the title-edit fast path looks up by name
ci-okci.ymlevery event, if: always()required checkneeds quality and codeql; runs pr-title and both agent-session-url checks as steps, then the verdict

The informational jobs that needed a full build move to a new extended.yml that runs nightly and on workflow_dispatch. Coverage runs there on master with the same shared-key: coverage as the PR job, so it seeds the cache that PR coverage restores and gives a daily number for master. msrv runs as cargo +<rust-version> check, with the version read from Cargo.toml, so the toolchain file can no longer override it and the job follows the declaration. The macOS check and compose smoke move unchanged. None of these jobs carries continue-on-error, since nothing gates on extended.yml. A report failure job opens or comments on one tracking issue when any of them fails. It holds issues: write at job level with no checkout, because zizmor 1.26.1 rates the same permission at workflow level as high and would fail the gate. A small bun pin job runs there too: every setup-bun step reads .bun-version, which no dependabot ecosystem raises, so the job fails once a newer bun is released and the tracking issue asks for the bump (#1922).

Coverage stays on pull requests because testing.md asks an author to edit the coverage baseline in the same PR that moves it, and that rule needs a per-PR number.

The release gate

release-plz’s verify job, the 27-job re-run of quality.yml, is deleted. A release-gate job replaces it:

  1. It lists every package cargo metadata marks publishable and looks each version up on crates.io. Any lookup error counts as pending. If nothing is pending it exits in about 30 s without setting its verified output.
  2. If something is pending, scripts/wait-for-ci-gate.sh waits for the ci.yml push run on the same sha and requires that run’s ci-ok job to have concluded success. It fails closed on API errors, on any other conclusion, and after 90 min.
  3. release-plz release needs release-gate and runs only when verified is 'true'.

The gate binds to the ci.yml push run because a check-run name proves little. Any workflow can post a check-run called ci-ok on a master sha. ci.yml’s pull_request trigger has no branches: filter, so a PR from master into a branch with an edited ci.yml reports its own ci-ok on master’s head sha, and outside workflows already write there (128c647 carries 44 add to project board check-runs). A ci.yml push run on sha X can only come from ci.yml at X. release.yml still matches check-run names, which #2034 tracks.

A detector that wrongly reports nothing pending delays a release. It cannot publish an unverified one, because the publish depends on verified rather than on the detector’s success. check-release-handoff.py asserts that binding, with self-test fixtures for a dropped needs, a dropped if, and a wait replaced by a name lookup. The job holding release-plz’s contents: write token never sits on a runner through the wait. The deadline is 90 min because push-run time to ci-ok measured a median of about 29 min and a maximum of 61.1 min over 26 runs; release.yml’s 45 min works only because it starts after tagging.

release-plz also stops dispatching ci.yml on the release PR by default. The dispatch runs only while the PR carries the release:ready label. Of the 33 release-PR dispatches from 09-21 to 09-28, a longer window than the 27 pushes above, 11 were cancelled mid-flight.

Smaller changes

  • Every rust-cache step in quality.yml, ci.yml and extended.yml saves only on refs/heads/master. PR runs restore master’s caches and write none, which keeps the cache under its 10 GB limit. extended.yml’s nightly schedule runs on master, so it still saves. engine-integration.yml never runs on a master push, so it restores rust build’s cache through a shared key and saves none (#2203).
  • pr-title and both agent-session-url checks move into ci-ok as steps. The PR-body check reads the live body from the API on pull_request instead of the frozen event payload, which closes the gap in #2035 where a body edited mid-run could still merge.
  • The codeql matrix becomes two legs. release.yml waits on the check names in RELEASE_REQUIRED_CHECKS, whose default lists ci-ok and the four current codeql legs, so that default changes in the same PR as the matrix. The new leg’s name has no comma because release.yml splits the list on commas. An admin deletes the RELEASE_REQUIRED_CHECKS repository variable first; it equals the default today, so deleting it changes nothing. The shared leg runs one init and one analyze for the three languages, then uploads each language’s SARIF under its old /language:<name> category, so code scanning keeps the same four configurations and none goes stale.
  • The helm chart renders run the checker with uv run --script, which installs only the pyyaml pinned in the script’s inline metadata (#1901), so the job stops building the wheel (#2038). That accidental build was the gate’s only pass over pyproject.toml, so the package step gains a maturin sdist that checks the PyPI packaging in seconds without compiling.
  • Outside the gate, engine-integration.yml cancels a superseded sim smoke run per ref on pull requests, and docs.yml uploads the Pages artifact only on the events that deploy it (#2036).

Each item ships as its own PR under #2025. The helm fix goes first as the cheapest, then the release gate as the largest single cut (about 98 jobs per merge down to 71).

What stays fixed

  • ci-ok is the only required check, with the same name and job id. Branch protection requires the ci-ok context from the GitHub Actions app, check-release-handoff.py asserts it, and the release gate finds the job by that name.
  • gate-ok keeps its name, its needs: [quality, codeql] and its guard, because the title-edit fast path finds it by name. That fast path lets ci-ok go green on a PR title or body edit without re-running the gate, once it has found a successful gate-ok from an earlier run on the same head sha. The job stays in ci.yml; in quality.yml the API would call it quality / gate-ok.
  • A merged job keeps one named step per former check. Every check step runs under !cancelled(), so one failure never hides another. always() is reserved for the ci-ok verdict step, so a cancelled run’s ci-ok fails closed instead of going green with every step skipped. Check steps avoid it because superseded PR runs are cancelled, and always() would keep them on their runners.
  • Each merged job ends with a report step. It writes a table of checks and outcomes to the step summary and one ::error annotation per failure, titled with the old job name and the local command that reproduces it. It exits 1 when a step that must run on this event shows skipped. A skipped step reads as success to its job, the way a job skipped inside quality.yml read as success to its caller in #1562, where ci-ok went green without reading a single commit message.
  • quality.yml stays secret-free (#734). The only token in it is github.token, passed to zizmor as step env.
  • Advisory steps inside a blocking job are continue-on-error, install and setup steps included, and never share an install with a blocking tool. semver-checks stays advisory as ADR-0032 requires; a failure there becomes a ::warning::, never an ::error::.
  • No blocking check is skipped based on the diff. The in-job codeql (rust) skip on a PR with no Rust change is the one diff-based skip in the gate, and it cannot change a verdict: the codeql job never fails on alerts.

Consequences

Jobs per run

Jobs that take a runner, today (master at 51fbf06, with image smoke) and after the whole decision:

RunTodayAfter
PR push3611
PR title or body edit31
master push, ci.yml3310
master push, release-plz303 (5 on a release commit)
master push, release-PR dispatch3510, only while release:ready
isolated merge to master (ceiling)about 9813, or 23 while release:ready
master push, measured averageabout 57 (about 59 with #1950)about 11
merge_group run (queue off today)3510
publish-bootstrap verify276

The measured-average row applies the coalescing rates seen over the 27 pushes to the new layout. GitHub Code Quality, a repository setting, adds 2 jobs per push on top of these unless an admin turns it off.

Latency

The latency estimates come from a 20-slot first-in-first-out replay of the real 09-26 to 09-28 workload, with each merged job’s duration set to the sum of its members minus 5 s per removed job. They are simulations. The simulator over-predicts today’s queueing by 20-50%, so the fair comparison is the “after” column against the measured one, with the simulated baseline shown for scale. The measured column and the simulated baseline both predate #1950; the “after” column includes one image smoke per quality.yml call.

GateMeasured today (before #1950)Simulated, today’s layout (before #1950)Simulated, after
PR median, 09-28 13:30-16:12Z (n=13)10.7 min (p90 12.0)14.5 (p90 18.2)8.3 (p90 10.5)
master push median, 09-28 (n=7)16.9 (p90 31.5)19.8 (p90 25.1)9.1 (p90 13.0)
PR median, burst 09-26 23:00 to 09-27 01:00Z (n=12)32.0 (p90 47.4)38.3 (p90 45.5)14.8 (p90 26.0)
master push median, burst (n=7)27.8 (p90 43.3)30.5 (p90 41.2)11.6 (p90 15.8)

On a moderate day a PR gate gains little: against the measured values, 10.7 to 8.3 min at the median and 12.0 to 10.5 at p90. The large gains are the master push gate and PR gates during a burst. Over the 09-28 window, runner time falls from about 101k slot-seconds (the measured 92.1k plus one image smoke per quality.yml call) to a simulated 54.5k, and jobs from 1144 to 345. The no-queue floor stays at about 8.1 min, set by codeql (rust); PRs with no Rust change (39 of the last 80) drop to the nextest floor of about 6.5 min.

What gets harder

A failing check now shows up as a step inside a job. The PR checks list shows 11 names where it showed 36, and a reviewer has to open the red job to see which check failed. The report step carries the per-check signal, through the step summary table and the ::error annotations titled with the old job names. Re-running one failed check re-runs every check in its job.

One failing tool no longer has a runner to itself. !cancelled() keeps the other steps running after a failure, but a hung step or a lost runner takes its siblings’ verdicts down with it until the job is re-run. Every merged job carries a job timeout, static checks 20 min (#2132), ui, storybook, docs 25, rust lint and nextest / doctests 30 and rust build 45, and the semver check has a 20-minute step timeout of its own, so a hung step fails its job long before GitHub’s 360-minute default.

The critical path can move. rust lint takes about 450-560 s cold against 488 s for codeql (rust), so on a Cargo.lock bump it can become the longest job. PR runs no longer save caches, so every push of the PR that carries the bump misses master’s exact key. rust-cache then restores the entry saved for the old lockfile and rebuilds only what changed; only a toolchain change starts fully cold. The next master push saves the new key.

PR title feedback arrives when ci-ok runs, about 8 min after the push. The separate pr-title job used to answer within about half a minute on a quiet pool: over 55 PR runs its median wait for a runner was 19 s, and its p75 was 237 s.

The msrv job now checks 1.82 for the first time, and it will go red. The lockfile already resolves redis 1.7.0, tonic 0.14.6 and icu 2.3, which all declare rust-version = "1.88", while Cargo.toml still says 1.82. #2026 decides between raising the declared version and holding dependencies back. Breakage in msrv, macOS or compose smoke now surfaces up to a day late, as a tracking issue, rather than on the PR that caused it. Coverage on master becomes a daily number.

Releases gain a manual step: someone labels the release PR release:ready before its CI runs. On a release push, release-gate holds one runner and release-plz’s concurrency group for as long as the push gate takes. If it times out it fails red, and gh run rerun <id> --failed on that run is the recovery. A later push that reaches release-plz first gates its own sha and publishes from that tree, which is also verified.

The merge queue stays as ADR-0033 describes it, and this decision makes it affordable: a merge_group run drops from 35 jobs to 10. It is still off (#2029). When it is turned on, its build concurrency should be 1 or 2, not the 5 that merge-protection.md recommends, because five group builds at 10 jobs each would ask for 50 slots out of 20 and starve every PR gate.

The audit’s other findings are filed as #2026 through #2035 and #2037; the ones this decision depends on are linked above.

Rejected

  • A paths: filter on ci.yml: a workflow skipped by a path filter leaves the required ci-ok pending forever, so a docs-only PR could never merge.
  • Diff-based skipping of blocking jobs: a skipped job inside quality.yml reads as success to its caller, the mechanism behind #1562. With the queue off, one classifier miss lands on master unchecked.
  • A check-run name lookup for the release gate: anything can post a check-run named ci-ok on a master sha, as the release gate section explains.
  • Self-hosted runners for PR jobs, the owner’s Pi and workstation included: rolter is public, and a fork PR needs approval only when its author is a first-time contributor, so a returning contributor’s code would run on that host unreviewed.
  • ARM or ubuntu-slim runners: they draw on the same plan pool, so they change the hardware without freeing a slot.
  • parallel: or background: steps: actionlint cannot parse them yet (rhysd/actionlint#693, open as of v1.7.12), and actionlint is a blocking step.
  • Coverage inside nextest: the blocking test verdict would then come from an instrumented build.
  • Caching bun and Playwright downloads: it would save about 2 s and 26 s per run, into a cache that is already over its limit.
  • Merging the docs build and deploy jobs: the build tools would then run with pages: write.
  • Running the labeler only on opened and reopened: its rules match changed files, so a PR whose later push first touches ui/ would never get its area label (#2036).
  • Folding image smoke into static checks: the simulation, run before the release:ready change was added to the layout, gives the same latency either way (PR median 8.3 min in both, burst 15.1 min folded against 15.4 separate) and it would save one slot per call, but static failures would then show about 5 min later. It stays separate until #2037 stops the cold build.

API surface

rolter speaks the OpenAI and Anthropic HTTP APIs so existing SDKs work unchanged — point them at the gateway base URL and use a rolter virtual key.

What this surface guarantees across releases is the dialect, not a frozen schema: the v1 in the path is OpenAI’s, so there is no /v2/ to move to, and following an upstream dialect change is not treated as a rolter breaking change. The rolter-owned parts — virtual-key auth, model addressing, the error envelope, GET /v1/models — are held stable on the same terms as the control API. See ADR-0032.

Authentication

  • OpenAI-style: Authorization: Bearer <virtual-key>
  • Anthropic-style: x-api-key: <virtual-key>

When no virtual keys are configured the gateway runs open (useful for local dev).

Endpoints (v1)

MethodPathNotes
POST/v1/chat/completionsOpenAI chat; streaming via "stream": true (SSE)
POST/v1/completionsOpenAI legacy completions
POST/v1/responsesOpenAI Responses; provider-native passthrough, streaming supported
GET, DELETE/v1/responses/{id}retrieve or delete a tenant-scoped native Responses resource
POST/v1/responses/{id}/cancelcancel a tenant-scoped native Responses resource
GET/v1/responses/{id}/input_itemslist input items for a tenant-scoped native Responses resource
POST/v1/messagesAnthropic Messages; streaming supported
POST/v1/embeddingsOpenAI embeddings; non-streaming
POST/v1/rerankCohere/Jina rerank; non-streaming
POST/v1/images/generationsOpenAI image generation; non-streaming
POST/v1/audio/speechOpenAI text-to-speech; binary audio response
POST/v1/audio/transcriptionsOpenAI speech-to-text; multipart/form-data upload
POST/v1/audio/translationsOpenAI audio translation; multipart/form-data upload
GET/v1/realtime?model=…OpenAI-compatible Realtime API; WebSocket relay, metered per response turn — see below
GET, POST, DELETE/mcp/{server}/{path…}authenticated Streamable HTTP/SSE MCP proxy
GET/v1/modelslists route names, provider-pinned and group addresses (see Model listing)
GET/openapi.jsonOpenAPI 3.1 description of this request surface (self-contained, no external assets)
GET/docsinteractive Scalar API reference (assets embedded in the binary — works air-gapped)
GET/service-info landing (version + links to docs/openapi/health)
GET/healthzliveness — process is up, no dependency checks
GET/readyzreadiness — 503 while draining (see Health & readiness)
GET/metricsPrometheus exposition

Request header forwarding

By default the gateway forwards trace context and nothing else. An operator can name additional client headers to forward with the forwarded_headers allowlist in the client policy.

anthropic-* headers are the exception: they forward by prefix, not by allowlist entry (#1013). Any inbound header whose name begins with anthropic- is passed unchanged to an Anthropic-dialect upstream, whether or not anyone has heard of it.

This is not a convenience. Anthropic extends its protocol through headers: a capability pairs an anthropic-beta value with body fields, and the pair travels together. An allowlist is a closed list against a vendor that ships new capabilities as new header values, so every new beta would silently break until an operator added it by name — and the failure is not a graceful downgrade. Stripping the header while the body passes produces a hard 400 (Extra inputs are not permitted), while capabilities like extended context go silently unavailable because the upstream never sees the request for them.

Two rules bound it:

  • The match requires the separator (anthropic-), so a header merely starting with those letters is not in the namespace.
  • These headers only reach a provider that speaks the dialect. They are collected before the balancer picks a target, so the proxy drops them again for an upstream that has no use for them.

anthropic-version

The client’s value wins when it sends one; the configured compatibility.anthropic_version is the fallback. That pin exists for translated requests — an OpenAI-dialect call rewritten to Anthropic, where no client version exists — and that is still exactly what it does. Overriding a version a native client did state would silently change the protocol it asked for. Exactly one value is sent either way.

Realtime WebSocket

Connect with the usual gateway bearer key and the public route model as a query parameter:

wss://gateway.example.com/v1/realtime?model=gpt-realtime

rolter authenticates and selects an upstream before accepting the client upgrade, then pins that upstream and its selected provider key for the session. Text, binary audio and WebSocket control frames are relayed in both directions without application-level buffering. If the upstream drops, the client must reconnect; rolter does not fail a live session over to another target because replaying audio or tool events is unsafe.

The WebSocket-first implementation supports the OpenAI Realtime event stream, including session.update, input_audio_buffer.*, response.*, and function-call events. WebRTC/browser ephemeral-token handoff is not exposed by the gateway yet.

A session is admitted through the same budget, unpriced_policy and rate-limit checks as an HTTP request, on the same key, project, team and org scope, and a refusal is the HTTP status and error body a chat request would get (402 insufficient_quota, 402 model_unpriced, 429 rate_limit_exceeded). Opening a session takes one rpm slot.

Once open, the session is metered per response turn: each response.done event’s usage becomes one request_logs row, priced and attributed like an HTTP row, and its cost and tokens are added to the scope’s budgets and tpm windows every [realtime] usage_flush_secs. When a budget runs out mid-session the client receives an error event with code insufficient_quota, then a 1008 close. A gateway shutting down closes each live session with 1001 (going away) after metering it, and refuses new upgrades with 503. Realtime metering has the full model, including how a response cut short is recorded. Guardrails and plugins do not run on realtime events yet (#1880).

MCP gateway

/mcp/{server} and any path beneath it proxy an organization-scoped MCP server registered in the control plane. The caller supplies a normal rolter virtual key. Unlike LLM routes, MCP calls require a database-backed key minted by a user: that owner must have a live, unrevoked and unexpired OAuth session for the named server, and its scopes must cover the server’s required_scopes. The gateway fails closed before connecting when any owner, server or scope check fails.

The caller’s virtual key is never forwarded. Rolter replaces it with the session’s OAuth bearer token and preserves end-to-end MCP headers, body, query, status and response stream. Streamable HTTP and legacy SSE registrations use this path; stdio and WebSocket registrations currently return mcp_transport_unsupported rather than bypassing authorization.

Routing

The model field in the body selects a route. The route’s strategy picks a target; rolter rewrites model to the target’s upstream model id and forwards with the provider’s credentials. Session affinity uses x-session-id when present.

When the selected upstream speaks the other chat protocol, rolter translates OpenAI Chat Completions and Anthropic Messages in both directions. Translation includes system/developer instructions, sampling and stop parameters, function tools and tool results, token usage, finish reasons, and live SSE events. Image and document inputs retain URL, base64 media type/data, and file references. Blocks with no equivalent in the target protocol (for example OpenAI input audio sent to an Anthropic Messages upstream) are preserved as opaque content blocks; the target may reject them rather than rolter silently dropping data.

The Gemini dialects — generateContent and Interactions — are the exception: their wire formats are typed part unions with no opaque carrier, so an unrecognized part cannot be passed through. Rather than drop it, rolter fails the request closed with 400 unsupported_content_part, naming the part type and the upstream dialect:

{
  "error": {
    "message": "the interactions upstream has no equivalent for content part type 'input_audio'; remove it or route this model to a provider that accepts it",
    "type": "invalid_request_error",
    "code": "unsupported_content_part",
    "param": "messages"
  }
}

This applies to every client dialect (Chat Completions, Messages, Responses) and covers new part types a client SDK starts sending. Only text/input_text and image_url/input_image have Gemini equivalents today; route models that need other modalities to a provider whose dialect carries them.

Model listing

GET /v1/models answers with three kinds of id, filtered to what the caller’s virtual key may reach. A key minted in the store sees only its own org’s routes, providers and groups, the same boundary the route authorization contract enforces on a call (#1844); a key from the gateway’s config file sees them all:

Idowned_byWhere it comes from
a route name (chat)rolterevery configured route, plus the built-in fake-llm unless a route the key can use shadows it
provider-slug/modelthe provider’s namethe upstream models the provider’s routes name, plus the catalogue the provider reported to its health probe
group-slug/modelthe group’s namethe union of its member providers’ models (a member with an explicit model rewrite contributes that one)

The provider-pinned half used to be derived from route targets alone, which under-reported a fleet: provider-slug/model resolves for any model the provider serves (ADR-0017), so a provider with five models behind one route answered 200 for all five but listed two. A client that builds a model picker from /v1/models could not offer the rest (#1647).

The catalogue comes from the probe the health sweep already sends. For every provider kind whose liveness endpoint is a model list — everything except TEI, and except a provider whose health.path was overridden — the sweep now parses that response instead of discarding it, and caches the model ids per provider. Two consequences follow from reusing the probe:

  • The wider listing needs [health] enabled = true. Without active probing nothing ever asks a provider what it serves, and the listing stays exactly route-derived. Addressing is unaffected either way: an unlisted provider-slug/model still routes.
  • It costs no extra upstream traffic and no new configuration. The probe interval is the refresh interval; the cache survives config reloads and drops a provider a reload removed.

Two bounds keep the listing from growing without limit, since it is the product of the fleet and each provider’s catalogue:

  • at most 200 models per provider are kept from a catalogue, which is where an aggregator’s several-hundred-entry /v1/models is cut. The cap is per provider so no single one crowds out the others, and it never removes a model a route target names — that set is listed regardless
  • at most 1 MiB of a probe response is read before the body is abandoned unparsed, so a liveness probe can never pull an unbounded response into the gateway

Both dialects read the same endpoint: rolter serves one /v1/models for the OpenAI and Anthropic surfaces alike.

OpenAI Responses

POST /v1/responses is routed by its required model field. Native OpenAI providers receive the request and SSE events unchanged. For Chat Completions or Anthropic Messages upstreams, rolter translates the common text, multimodal, function-tool, tool-result, sampling, and usage fields in both directions and emits Responses-shaped events to the caller. Responses-only features without a wire equivalent (for example background, store, previous_response_id, and provider-specific reasoning controls) are not forwarded to those older surfaces; use a native Responses provider when those features are required.

For native OpenAI providers, rolter records the selected provider, target, upstream model, provider credential fingerprint, and native response ID after a successful creation. GET/DELETE /v1/responses/{id}, cancellation, and input-item retrieval are then pinned to that record. Records are isolated by virtual key, retained for 24 hours by default, bounded to 100,000 entries per gateway process, and removed after a successful delete. Configure these limits with [responses] registry_ttl_secs and registry_max_entries; setting either to 0 disables registration.

The registry is process-local. Multi-replica deployments must keep lifecycle requests sticky to the gateway replica that accepted creation; records do not survive a gateway restart. Route changes do not retarget an existing response. If its provider is removed, its provider kind changes, or its credential is rotated away, the record becomes unavailable. Unknown, expired, deleted, cross-key, and unavailable records all return the same 404 response_not_found error so route ownership is not leaked.

Each lifecycle call is also re-authorized against the current configuration, exactly as a new request would be: the model the caller named, the route that served the response (visibility, route policy, provider list) and the provider that holds it. Access revoked since creation is refused with the usual 403 codes before anything is forwarded, and a response whose route has since been removed is refused with 403 route_not_allowed (fail closed). See the route authorization contract.

Responses translated through Chat Completions or Anthropic Messages retain an ownership record but expose no lifecycle capabilities, because those upstream contracts do not retain an OpenAI Responses resource. Their lifecycle calls return 501 response_lifecycle_unsupported. Compaction and input-token counting remain unsupported for all providers.

Examples

# openai chat (streaming)
curl -N http://localhost:4000/v1/chat/completions \
  -H "Authorization: Bearer sk-rolter-dev" \
  -H "Content-Type: application/json" \
  -d '{"model":"gpt-4o","stream":true,"messages":[{"role":"user","content":"hi"}]}'

# anthropic messages
curl http://localhost:4000/v1/messages \
  -H "x-api-key: sk-rolter-dev" \
  -H "Content-Type: application/json" \
  -d '{"model":"claude","max_tokens":256,"messages":[{"role":"user","content":"hi"}]}'

# openai embeddings
curl http://localhost:4000/v1/embeddings \
  -H "Authorization: Bearer sk-rolter-dev" \
  -H "Content-Type: application/json" \
  -d '{"model":"text-embedding-3-small","input":["hello","world"]}'

# self-hosted vllm pool via a public model name
curl http://localhost:4000/v1/chat/completions \
  -H "Authorization: Bearer sk-rolter-dev" \
  -H "x-session-id: user-123" \
  -H "Content-Type: application/json" \
  -d '{"model":"llama","messages":[{"role":"user","content":"hi"}]}'

Multipart audio (/v1/audio/transcriptions, /v1/audio/translations) forwards the upload verbatim and routes on the model form field; the route target’s upstream model name is not rewritten into the multipart body, and variant routing / per-model param defaults (JSON-only) do not apply. Authorization does apply in full: the upload passes the same model, visibility, route-policy and provider gates as a JSON request (route authorization contract).

User journeys

How the people who use rolter get from “I have a job to do” to “it is done”, written as scripts: one per person and goal, step by step, with the branches a real deployment takes. They exist for three reasons at once:

  • Planning. Each script describes the journey as it should go. Where the product does not support a step yet, the step says so and links the issue, so the gaps read as a list in the gap register instead of being rediscovered by every new user.
  • Testing. The scripts are what the pre-1.0 live pass (#1789) walks, on the dogfooding fleet: headless by an agent driving the dashboard in Chromium plus the API, and by a real person while an observer watches the telemetry. See running a script.
  • Onboarding. The steps that already work end to end are also the public guides, minus the gap tracking.

The user-facing docs describe features; these describe people. When the two disagree about what a person can do, one of them is wrong — file it.

The people

rolter has three scoped roles — viewer, member, admin — granted at an org, a team or a project, plus the deployment-wide superadmin (the admin token, or an account with the superadmin bit). Custom roles and access profiles can only widen a scoped role (see RBAC & auth). Every persona below is one of those, at one scope:

PersonaRole todayScriptTheir question
Platform operator / org adminsuperadmin, or admin at the orgplatform-admin.md“How do I stand this up, connect our models and hand it to people?”
Team leadadmin at a teamteam-lead.md“How do I give my team models, keep them inside a budget, and see the bill?”
LLM / ML / software engineermember at a projectengineer.md“How do I get a key, call models, and see why my call failed?”
Application / service accounta virtual key (no human)app-service.md“What happens to my traffic, my limits and my key over time?”
Finance / FinOpsviewer at the org (admin to cap)finops.md“What did we spend, on what, for whom — and can it stop at a limit?”
SecOpssuperadmin (a narrower role: #1834)secops.md“Can I prove who reads what, and that secrets never land in a log?”
DevOps / SRE (maintenance only)superadmin (a narrower role: #1834)devops.md“Is it healthy, and can I upgrade, drain and restore it without a surprise?”
Viewer (stakeholder, PM, auditor)viewer at a project, team or orgviewer.md“What is happening here — and can I keep my own view of it?”

SecOps and DevOps are superadmin today only because nothing narrower reaches the deployment-wide settings they need. Both scripts start from that and mark it as the first gap.

The simplest paths

Most people only ever need one of these. Every longer script is a branch off one of them.

  1. Five minutes, no credentials. rolter easy-up, open the dashboard and send a Playground message to the built-in fake-llm model. Proves the gateway and the dashboard before a single provider key exists. Add --clickhouse-url pointing at a ClickHouse that has rolter’s schema (clickhouse/*.sql) and LLM Logs shows the row too, which proves the log pipeline. (platform-admin A0-a)
  2. One hour to a team deployment. Install with Postgres, Redis and ClickHouse, sign in as the seeded admin, follow the dashboard’s Getting started card — connect a provider, add a route, mint a key — and hand people either an invitation or single sign-on. (platform-admin A0-c to A4)
  3. Configuration as code. Keep providers, groups, routes, prices and prompt templates in a rolter.toml, apply it with rolter-seed --import (desired state), and read the live state back with rolter config export. What the file cannot carry — keys, budgets, SSO, policy — stays in the dashboard or the API. (platform-admin A3-d)
  4. An engineer’s first call. Sign in, Settings → My Virtual Keys → Generate virtual key, point the OpenAI or Anthropic SDK (or a coding agent) at the gateway with that key. (engineer E1–E4) Until #1846, the dashboard half works only for someone who also holds a role at the org: an invitation to a project grants nothing above it.

How to read a script

Each script is a table of steps. A step names what the person does, where they do it — the dashboard path first, the API equivalent second, so the same step can be run by hand, by a browser driver or by curl — what they should see, and its status:

statusmeaning
verifiedpassed in a recorded dogfood run (the run log names the date)
worksthe product supports it and nothing is known to be wrong, but no recorded run has walked it yet
partialit can be done, with a workaround the step spells out; the linked issue removes the workaround
gapthe product cannot do this yet; the linked issue is the plan
bugit should work and does not; the linked issue is the bug

Where the path forks — no identity provider or OIDC, one model or a fleet — the script names the fork, the question that decides it, and gives each branch its own steps (A1-a, A1-b, …). A branch is walked in a run only when the run is testing that branch; the rest are still read, because a branch nobody walks rots.

Dogfood data

Every script runs against the stack just dogfood brings up (the dogfooding fleet). Seed the fleet and the personas once:

just dogfood                                    # the stack, SigNoz, the sheet
just dogfood-seed                               # 15 fake providers, 3 groups, 11 routes
just dogfood-personas                           # one account per persona

personas.sh is idempotent and prints the roster. Every account shares the dogfood password from integration/dogfood/creds.env:

accountrolescopepersona
dev@rolter.localsuperdeploymentplatform operator, DevOps, SecOps
orgadmin@rolter.localadminorg defaultorg admin
lead@rolter.localadminteam defaultteam lead
engineer@rolter.localmemberproject default/defaultengineer
engineer2@rolter.localmemberproject research/sandboxan engineer on a different project
viewer@rolter.localviewerproject default/defaultviewer
finops@rolter.localviewerorg defaultFinOps

The fleet lives in project default/default: routes gpt-4o, gpt-4o-mini, claude-sonnet-4, llama-3.1-8b (round robin), llama-3.1-8b-cached (cache-aware), llama-3.1-8b-fastest, qwen-32b (power of two), mistral-7b (predicted latency), gemma-27b (weighted), deepseek-r1 (adaptive) and text-embedding, plus group addresses such as vllm-a100/…. Three targets are bad on purpose — vllm-a100-03 is slow, vllm-spot-01 answers 503 a quarter of the time, vllm-spot-02 takes 1.4 s to first token — so failure, retry and latency screens have something to show. research/sandbox has no routes; it exists so one project’s people can be shown not seeing another’s traffic.

Running a script

Headless — an agent drives it

This is how a script runs when nobody can open the dashboard: the stack is in a cloud container, or the run is a regression pass.

  1. Bring the stack up and seed it (above). Mint nothing by hand: every key and account a script needs is made by its own steps or by personas.sh.
  2. Walk the steps as the persona. Dashboard steps are driven in headless Chromium (Playwright) signed in as the persona’s account — a screenshot of each step’s end state is kept, because “the button was there” is not the same claim as “the button was usable”. API steps use curl with the persona’s session (POST /api/v1/auth/login) or the virtual key the script minted.
  3. Check every Expect against the source of truth rather than the screen: the API’s answer, a request_logs row in ClickHouse, an audit_log row in Postgres.
  4. Record the result per step in the run log. A failed step is either a bug in rolter (file it), a wrong script (fix the script in the same PR as the run log), or a known gap (already linked).

just dogfood-journeys does steps 2 and 3 for every script: one runner per persona in integration/dogfood/journeys/, results and screenshots in integration/dogfood/.journeys/, and a summary.md to copy into the run log. just dogfood-screens adds the persona × screen matrix. See walking it as someone else. A runner step and its script step share an id; when a script changes, change its runner in the same PR.

Watched — a person drives it, an observer watches

This is the #1789 pass: someone who did not build the screen uses it, and the struggle is the finding.

  1. The person runs the stack locally (just dogfood) or on a shared host and signs in as the persona. They get the script’s goal, not its steps — the steps are what the observer checks them against.
  2. The observer runs just dogfood-watch (live ui_events and gateway traffic), keeps SigNoz open on rolter · dashboard UX, and notes every place the person hesitates, backs out, reaches for a refused control (refused_click) or abandons a form (form_abandon).
  3. Afterwards the two walk the script together and mark each step. A step the person completed by a route the script did not foresee is a finding too: the script, or the screen, is wrong about how people think.

What the observer watches

signalwheretells you
screen views and struggleClickHouse ui_events, just dogfood-watchwhich screen, how long to interactive, refused clicks, abandons
requestsClickHouse request_logs, LLM Logswhat reached the gateway, the status, the target, the cost
who did whatPostgres audit_log, Governance → Audit Logsevery mutation a step claims to make
tracesSigNoz, rolter · overviewwhere a slow step spent its time
the gateway’s own viewGET :4000/metrics, Cluster, Adaptive Routingconfig version, breaker state, queue, adaptive engagement

Run log

datemodescriptsresult
2026-09-24headlesssmoke over A0, A3, P2, P5 (API only)stack up; fake-llm on every dialect; 11/12 fleet routes answer; found #1815, #1816, #1817, #1818, #1819, and the unauthenticated analytics read fixed in #1820
2026-09-24headlessrequest-log visibility for every personaafter #1820: org admin all rows and bodies; team lead and engineer their project’s rows and bodies, no provider health (#1833); engineer2 nothing; viewer and FinOps rows with bodies withheld
2026-09-24headlessevery screen as every persona account (49 screens × 7 accounts)org admin 32 usable, FinOps 28; team lead, both engineers and the viewer 5, because their scope switcher stops at the org (#1846); the superadmin cannot open the Playground (#1847); non-admins get the setup checklist as an error (#1848)
2026-09-24headlessall eight scripts, 94 steps, just dogfood-journeys59 pass, 17 bug, 10 partial, 8 gap. New: #1844–#1856. Script claims the run disproved were corrected: A1a.4, S2.1, P1.2, P2.4, A3.14

Add a row per run. The full walk of every script is the #1789 pass.

Gap register

Every step marked partial, gap or bug, in one place. Milestones and priorities live on the issues.

issuewhat is missingblocks
#1846team- and project-scoped members cannot select their own scope in the dashboardE1–E3, T1, V1, V4: every persona below the org
#1844a route with no visibility list is served to virtual keys of every orgA3, A4 on any multi-org deployment
#1845a same-named route in a second project freezes config for every gatewayA3, T3
#1847a superadmin with no membership cannot open the Playground or mint a personal keyA3.4
#1853the Playground asks for models before its key is live, then defaults to a dead routeA3.4, E3.1
#2008nothing can check that a provider serves an upstream model name (UI: #2009)A3.3a
#1851cache_aware sends every request to one replica and never checks loadA3-d, P2.4, P5
#1850a team admin cannot list their own team’s membersT2, T6
#1852no second-factor enrolment at sign-in when an org requires oneA1a.4, S2.1
#1854sign-in and second-factor audit rows are written with no org, so nothing reads themS2, S6
#1856a flag stored on but unavailable blocks every feature-flag changeD3.3
#1848the Dashboard’s setup checklist shows an access error to everyone below adminE1, V1, F1
#1861LLM Logs cannot look a request up by the x-request-id a client received (API: #1863)E5.2
#1395requests refused before routing (401, 402, 403, 404, 429) never reach LLM LogsE5, P5.4
#1855no per-provider queue depth or in-flight metricD2.4, D3.1
#1815the provider queue serialises every provider to one in-flight requestP5, D3 — any concurrent traffic
#1822self-service account area (epic)E10, V2, V3, V5
#1823display name and bio, editable by the account itselfV2, E10
#1824preferences and defaults stored server-sideV3, E10
#1825saved filter presets on LLM Logs and the DashboardV5, F2
#1831members cannot read their own MCP tool-call logsE6
#1841a deprovisioned person’s own virtual keys keep workingT6, S2
#1840anonymous /api/v1/config still describes the upstream topologyS1.3
#1133a database-backed provider cannot opt out of the hosted host pinA3-e
#1826LDAP is implemented but not reachable from sign-inA1-c
#1827no SAML single sign-onA1-b (SAML-only IdPs)
#1828invitations are links an admin passes on by handA1-a, T2
#1829alert rules are superadmin-only and deployment-wideA5, T5, F4
#337no warning before a budget blocksA5, T4, F4
#1830no budget per person across their keysT4, F3, E9
#1833project members cannot see the health of their own routes’ providersE5, T5
#1834no operator or security-auditor role short of superadminD0, S0
#1835payload redaction matches key names, not secrets inside text; no previewS3
#1085one subject’s logs and captured payloads cannot be erased on requestS3.6
#1836no scoped control-plane token for automationP4, D6
#1837shared project keys cannot be rotated in placeP3
#1838no CSV export of spend or logsF5
#1816a request abandoned while waiting on its upstream is logged with no providerD4, E5
#1817the dogfood fleet’s adaptive route never engagesA3-d (adaptive), D2
#1818rolter-seed --import is silent about sections it skipsA3-d
#1819compose ClickHouse fails where nofile cannot reach 262144A0-b on constrained hosts
#1890the compose stack takes no secrets and its gateway ignores the control planeA0-b, A0.6
#1939a default Helm install fails its own strict preflightA0-c, A0.6
#1897rolter-seed ships in no image or packageA0.8
#1891the published image binds loopback, so docker run -p reaches nothingthe quickstart’s docker run
#1832raw MCP traffic inspection for local servers (mcp-snoop)E7
#1839a member cannot route their own local model through rolter (idea)E8

Public guides

The paths that already work end to end are published as user-facing guides in docs/user-docs/guides/, without the gap tracking: the admin’s first hour, connecting models, an engineer’s first call, and tracking spend. When a gap here closes, the matching guide gains the step.

Platform operator and org admin

The person who stands rolter up and hands it to everyone else. In a small shop that is one person holding superadmin; in a larger one it splits into a platform operator (superadmin: the deployment, its policies and its fleet) and an org admin (admin at the org: its providers, people, keys and budgets). The scripts mark which of the two a step needs.

Goal: from nothing to “our people and our applications call our models through rolter, inside limits we chose, and we can see what they did.”

Dogfood accounts: dev@rolter.local (superadmin), orgadmin@rolter.local (admin at org default). See dogfood data.

The observer watches: audit_log for every mutation below, ui_events for the Getting started card and the sheets, and Cluster Config for the config version reaching the gateway after each change.

A0 — choose the install shape

The first fork, decided by one question: who will use it, and from where?

branchwhenshape
A0-a“I want to see it work” — one person, one laptoprolter easy-up: no database, no keys, loopback only
A0-bone person, one host (trial)docker compose -f docker/docker-compose.yml up -d: Postgres, Redis, ClickHouse, both planes (open by design, see below)
A0-cproductionHelm (charts/rolter), managed Postgres/Redis/ClickHouse, TLS in front, secrets from a manager
A0-dno internet at allA0-b or A0-c from mirrors (air-gapped)

docker/docker-compose.yml is the local stack and is open on purpose: the control plane binds every interface with ROLTER_ALLOW_OPEN_MODE=1, 4001 is published, no admin token is set, the file reads none of the A0.5 secrets, and its gateway serves the bundled rolter.toml instead of polling the control plane. It also publishes Postgres (example rolter/rolter login), Redis (no password) and ClickHouse (passwordless default user) on every interface, so an admin token added by hand still leaves the data reachable around it. A0-b is a single-person trial until a compose shape for a shared host exists (#1890); a team goes to A0-c.

A0-c has its own gap: with default values the chart’s preflight init container cannot pass (preflight.strict fails on the 0.0.0.0 bind the Service needs, and the gateway pod runs the control plane’s checks), so today an install sets preflight.enabled=false (#1939).

A0-a — five minutes, no credentials

#stepwhereexpectstatus
A0.1start everythingrolter easy-upboth planes up; the startup banner prints a ready-to-run curl for fake-llmworks
A0.2call the built-in modelthe printed curl, or the dashboard’s Playgrounda deterministic lorem-ipsum completion; no provider or key configuredverified
A0.3see the requestObservability → LLM Logs (needs ClickHouse)the row, with model fake-llm and a latencyworks
A0.4notice the deployment is openthe dashboard banneran open-mode banner: no admin token, so anyone who reaches the port is superadminworks

A0-b / A0-c — a real deployment

#stepwhereexpectstatus
A0.5generate the deployment secretsrolter init (preflight)admin token, internal token, KEK, key pepper and session pepper written somewhere they outlive a restartworks
A0.6bring the stack updocker compose … up -d, or helm installPostgres, Redis, ClickHouse healthy; migrations applied on control-plane bootverified (compose datastores); partial on constrained hosts — #1819; the compose planes do not take the A0.5 secrets — #1890; the chart’s default preflight cannot pass — #1939
A0.7check the configuration before trusting itrolter checkadmin token, KEK and database URL present; a warning for a missing key pepper, a missing session pepper or a control plane on every interfaceworks
A0.8create the first accountrolter-seed --admin-email … --admin-password …a superadmin that can sign in; the org, team and project default existpartial — #1897 (verified from a checkout; not in the image or package)
A0.9sign in and enrol a second factordashboard sign-in, then Settings → My Virtual Keys → Two-factor authenticationTOTP enrolled, ten recovery codes shown onceworks
A0.10confirm the planes agreeCluster Configevery gateway node live and converged on the current config versionverified

A1 — decide how people sign in

The second fork: does the company have an identity provider rolter should trust? Branches A1-a to A1-c are alternatives; A1-d and A1-e combine with any.

A1-a — no identity provider: invitations and passwords

#stepwhereexpectstatus
A1a.1invite a colleague at a scope and roleGovernance → Users → Invite user · POST /api/v1/orgs/{org}/invitationsa one-time link, valid for days, naming the role and scope it grantsverified
A1a.2get the link to themcopy the link into chat or email by handthe invitee receives itpartial — #1828
A1a.3the invitee accepts and chooses a passwordthe link opens Accept invitationan account with exactly the invited role; the link is dead once usedverified
A1a.4require a second factor for the orgGovernance → Single Sign-On → Org sign-in policy (mfa_policy)members without a factor are refused a session until they enrol (the confirmation says so)partial — #1852
A1a.5withdraw a link sent to the wrong addressGovernance → Users → Pending invitations, the × · DELETE /api/v1/invitations/{id}the link stops working at once; the address can be invited againworks — #2054
A1a.6block a leaver, or remove their accountGovernance → Users, deactivate or Edit → Delete · /api/v1/users/{id}a confirmation names the account; sign-in is blocked and sessions endworks — #2055

A1-b — an OIDC identity provider (Okta, Entra ID, Google, Keycloak)

#stepwhereexpectstatus
A1b.1create an OIDC web app in the IdPthe IdP, redirect URI https://<control>/auth/sso/<slug>/callbacka client id and secretworks
A1b.2register itGovernance → Single Sign-On · POST /api/v1/orgs/{org}/sso-providersthe provider card; the login screen offers Continue with …works
A1b.3map IdP groups to roles at scopesthe provider card’s Group mappingsa user in platform-eng becomes admin of team platform, and nothing elseworks
A1b.4decide what an unmapped user getsthe provider’s default role (empty refuses them)an unmapped user is refused, or lands as the default role at the orgworks
A1b.5turn passwords off for the orgOrg sign-in policy: single sign-on onlyone button on the login screen; the superadmin keeps a password as the break-glassworks
A1b.6sign in from a private window as a mapped userthe login screenthe dashboard, scoped to what the mapping grantedworks
A1b.xthe IdP speaks only SAML——gap — #1827

A1-c — an LDAP / Active Directory directory

#stepwhereexpectstatus
A1c.1point rolter at the directory and bind users——gap — #1826

A1-d — provisioning from the IdP (SCIM), with any of the above

#stepwhereexpectstatus
A1d.1issue an org-scoped provisioning tokenGovernance → User Provisioning → Issue tokenthe token, shown once, named after the IdP connectorverified
A1d.2configure the IdP’s SCIM connectorthe IdPa test user is created in rolter as an org viewer, nothing moreverified
A1d.3map IdP groups to teams and rolesGroup mappings on the same screengroup membership in the IdP becomes a role in rolter on the next syncworks
A1d.4deprovision the test user in the IdPthe IdPthe account is deactivated, its sessions droppedverified
A1d.5their personal keys stop working toothe gatewaya key the leaver minted is refusedbug — #1841

A1-e — custom roles and access profiles (optional)

#stepwhereexpectstatus
A1e.1define a role that widens a built-in one by specific grantsGovernance → Roles & Permissionse.g. “viewer, plus create routes in project X”verified
A1e.2hand it to people, with an optional model/route policyGovernance → Access Profilesthe grant shows in GET /api/v1/rbac/effective for those peopleverified

A2 — lay out the tenancy

#stepwhereexpectstatus
A2.1create teams and projects that match how money and access flowthe scope switcher’s + · POST /api/v1/orgs/{org}/teams, /teams/{id}/projectsthe chain appears in every screen’s scopeverified
A2.2decide who reads prompts, per projectthe gear beside the project → Viewers can read captured payloadsoff by default: members and admins read bodies, viewers see rows only (#1820)verified
A2.3create business units and customers for attributionGovernance → Business Units, Customersslugs spend can be recorded againstverified

A3 — connect models

The third fork, and the one with the most branches: how many models, from how many places, and does traffic need spreading?

branchshapebalancing
A3-aone hosted provider, one or two modelsnone — one target per route
A3-bthe same model from two providersfailover: retries and cooldowns move traffic off a failing target
A3-cseveral vendors behind named routesnone or failover, per route
A3-da self-hosted fleet, many replicasload-balanced: a strategy per route
A3-ea hosted API reached through a proxy or a re-implementationas A3-a, with the host pin relaxed

A3-a — one provider, one route

#stepwhereexpectstatus
A3.1add the provider with its keyModels → Model Providers → + Add provider · POST /api/v1/orgs/{org}/providersthe key is sealed with the KEK and never shown againverified
A3.2check it before anything depends on itthe provider’s Test connectiona model list from the upstream, or the reason there is noneverified
A3.3add a route: public name → provider/modelModel Catalog → + Add model or Routing Rules → + Add route, one sheet · POST /api/v1/projects/{id}/routesthe public name appears in Model Catalog and in /v1/models for keys that may reach itverified (seed)
A3.3acheck the upstream name before savingModel Catalog → + Add model, the sheetthe provider’s catalogue lists the upstream model, or the reason it could not tellgap — #2008 (UI: #2009)
A3.4try itPlayground, the new modelan answer; a row in LLM Logs naming the provider and the costbug — #1853; #1847 for a superadmin with no membership

A3-b — the same model from two providers, with failover

#stepwhereexpectstatus
A3.5give the route two targetsthe model sheet’s Targets; Model Catalog lists them per rowboth targets listed with weightsverified
A3.6set the retry budget and cooldownSettings → Performance Tuning, Circuit Breakera transient 5xx on one target is retried on the other before the caller sees itverified (dogfood deepseek-r1: vllm-spot-01 503s never reached a caller)
A3.7watch a target tripCircuit Breaker, Observability → Dashboardthe failing target’s breaker opens, traffic moves, and it is probed back inworks

A3-c — several vendors behind named routes

#stepwhereexpectstatus
A3.8one provider per vendor, one route per public model nameas A3-a, repeatedclients ask for gpt-4o or claude-sonnet-4 and never learn which vendor answeredverified
A3.9pin defaults on a route and decide what callers may overridethe route’s params and param policytemperature defaulted, max_tokens capped, overrides allowed or deniedworks
A3.10fill in what callers omit, deployment-wideModels → Model Settings (superadmin)a default temperature and fallback model, never overwriting a caller’s valueworks

A3-d — a self-hosted fleet, load-balanced

#stepwhereexpectstatus
A3.11describe the fleet as a filerolter.toml: providers, provider groups, routes, prices—works
A3.12apply it as desired staterolter-seed --import rolter.tomlrows created or updated to match the file; the gateway picks them up within its poll intervalverified (4 s)
A3.13notice what the import did not applythe import’s outputsections it does not import ([adaptive_routing], [retry], budgets, keys) namedgap — #1818
A3.14choose a strategy per routethe model sheet’s Strategy, at creation; changing it later is #2208round robin for identical replicas, cache_aware for prefix-heavy chat, fastest/predicted_latency for latency, weighted for a canary, adaptive to let live latency, cost and load decideverified, except cache_aware: it sends every request to one replica — bug #1851
A3.15address a whole groupvllm-a100/meta-llama/Llama-3.1-8B-Instructthe group’s members share the trafficverified
A3.16turn adaptive routing onAdaptive Routing → Settings (superadmin)deepseek-r1 engages once it has samplesworks; dogfood never engages — #1817
A3.17take the live state back out as a filerolter config export --output rolter.tomlan importable file with no credential in itverified

A3-e — a hosted API behind a proxy or a local re-implementation

#stepwhereexpectstatus
A3.18point a hosted kind (e.g. openrouter) at another hostallow_custom_api_base = truethe provider reaches the gatewayworks in a gateway-only file; gap in the database — #1133 (the dogfood claude-sonnet-4 route 404s)

A4 — give access to people and applications

#stepwhereexpectstatus
A4.1make engineers members of their projectsA1-a invitations, A1-b mappings or A1-d groupsthey can mint their own keys (see engineer E2)works
A4.2mint a shared key for an applicationGovernance → Virtual Keys · POST /api/v1/projects/{id}/virtual-keysshown once; models and providers allow-lists, expiry, business unit and customer set on the keyverified
A4.3rotate that key later—a new secret on the same key with an overlap windowpartial — #1837 (mint a second key, redeploy, delete the first)
A4.4automate the control plane from CI—a token scoped to one projectgap — #1836 (the admin token is superadmin)

A5 — spend and budgets

#stepwhereexpectstatus
A5.1price every model that costs moneyModels → Pricing Overrides (superadmin), or [[model_prices]]cost_usd on every row; nothing counts as free by accidentworks
A5.2find unpriced trafficDashboard (unpriced share), LLM Logs (unpriced flag)the dogfood fleet shows 12 unpriced models — every fake routeverified
A5.3cap spend per org, team, project, key, unit or customerModels → Budgets & Limits → Add budget (admin at that scope)the next request past the cap gets HTTP 402; counters in Redisverified
A5.4cap throughputAdd rate limitHTTP 429 with Retry-Afterverified
A5.5hear about it before the cap—a warning at a thresholdgap — #337
A5.6alert on spend velocityAlerting → Rules, spend_velocity (superadmin, deployment-wide)a webhook when spend per hour crosses the lineworks; scoped rules — #1829

A6 and A7 — run it, and keep it safe

Day-2 operations (health, upgrades, drains, backups) are the DevOps script; identity hardening, payload redaction and audit are the SecOps script. An admin who is both walks those next.

Team lead

Admin at a team. Owns the team’s projects, the people in them, what they may call and what it costs — and nothing outside the team. Providers are the org’s: a team lead routes to them but does not add them (provider mutations take admin at the org, by design).

Goal: “my team has the models it needs, inside a budget I set, and I can see who spent what — without asking the platform team for every change.”

Dogfood account: lead@rolter.local, admin at team default (which holds the fleet’s project default). See dogfood data.

The observer watches: refused_click in ui_events — every control a team lead reaches for that is an org admin’s is a finding about what the screen offered, even when the refusal is correct.

T1 — arrive

#stepwhereexpectstatus
T1.1sign in (SSO or password)login screenthe dashboard, scoped to team defaultbug — #1846
T1.2see what the team is already doingObservability → Dashboard, LLM Logsthe team’s traffic and spend, with captured bodies (admin at the team), and nothing of other teams’verified
T1.3see what the team may callModels → Model Catalog, Routing Rulesthe routes of the team’s projectsbug — #1846 (Routing Rules refuses; the API answers)

T2 — bring the team in

#stepwhereexpectstatus
T2.1create a project for a new workstreamscope switcher + under the team · POST /api/v1/teams/{id}/projectsthe project, owned by the teamverified (API); the switcher’s + needs #1846
T2.2invite engineers as members of that projectGovernance → Users → Invite user, scope = the project, role = membera link per person; authorized at the project, so no org admin is neededworks — #2054 adds the scope picker
T2.3get the links to peopleby hand—partial — #1828
T2.4with SSO or SCIM instead, map the team’s IdP groupdone by an org admin once (platform-admin A1-b/A1-d)new joiners land in the project with no invitationworks

T3 — give the team models

#stepwhereexpectstatus
T3.1route a public model name to an existing providerRouting Rules in the team’s projectallowed: routes are project-scoped and the lead is admin above the projectverified
T3.2add a new providerModel Providers → + Add providerrefused, naming the Admin role at the org — a request to the platform teamverified (by design)
T3.3mint a shared key for the team’s serviceGovernance → Virtual Keysas platform-admin A4.2verified

T4 — keep it inside a budget

#stepwhereexpectstatus
T4.1cap the team’s monthly spendBudgets & Limits → Add budget, scope = the team · POST /api/v1/budgetsthe team’s requests get HTTP 402 once the month’s spend reaches the capverified
T4.2cap each project’s throughputAdd rate limit, scope = the projectHTTP 429 with Retry-After past the capverified
T4.3give every engineer the same personal allowance—“$50 a month each, however many keys they mint”gap — #1830
T4.4raise the budget mid-monththe budget rowbudgets cannot be edited: delete and recreate (the counter is keyed by scope, so spend so far still counts)partial
T4.5hear before the team hits the cap—a warning at 80%gap — #337

T5 — see what happened

#stepwhereexpectstatus
T5.1spend by model and by key over the monthDashboard, LLM Logs filtered by keythe team’s rows onlyverified
T5.2is an upstream the reason the team’s calls fail?Circuit Breaker, health rollupsprovider health is readable only with an org-level role — the lead sees nonegap — #1833
T5.3an alert on the team’s error rate—a rule the lead owns, over the team’s trafficgap — #1829

T6 — someone leaves

#stepwhereexpectstatus
T6.1remove their membershipGovernance → Users, the × beside the person’s role at the projectthey lose the project in the dashboard at onceworks — #1850 lists the team’s members, #2053 adds the revoke
T6.2their personal keys stop workingthe gatewaya key they minted for themselves is refusedbug — #1841
T6.3shared keys they minted as an admin keep workingthe gatewaythe team’s services do not go down because a person leftverified

LLM / ML / software engineer

Member at a project. Calls models from code, notebooks and coding agents; wants a key without filing a ticket, and — when something breaks — wants to find out where without waiting for an admin. Has no business with providers, budgets, people or policy, and the dashboard should not put those in the way.

Two things make this persona sharper than “a user with a key”:

  • Their MCP servers and models are often local — an MCP server a coding agent launched over stdio, a model on their workstation — so “where is the problem?” means their machine, rolter, or the upstream, and they need to tell which.
  • They read their own request bodies to debug. A member of the project reads captured bodies by default (#1820); a viewer does not.

Goal: “I call models through rolter with my own key, and when a call fails I can see why.”

Dogfood account: engineer@rolter.local, member of project default/default (the fleet’s project). engineer2@rolter.local is a member of research/sandbox and should see none of it. See dogfood data.

The observer watches: the time from first sign-in to the first successful call (ui_events screen views → the first request_logs row on the engineer’s key), and every screen the engineer opens while debugging E5 — the order they look in is the finding.

E1 — arrive

#stepwhereexpectstatus
E1.1sign inlogin screen (password or SSO)the dashboard, scope default/default; admin-only controls disabled with the role namedbug — #1846
E1.2find out what models existModels → Model Catalogthe project’s routes: gpt-4o, llama-3.1-8b, … with their capabilitiesverified

E2 — get a key

#stepwhereexpectstatus
E2.1mint a personal keySettings → My Virtual Keys → Generate virtual key · POST /api/v1/me/projects/{id}/virtual-keysthe key once, a name, an expiry; a viewer would be refusedbug — #1846 in the dashboard; verified through the API
E2.2narrow it to the models the job needsthe mint form’s model list/v1/models with that key lists only thoseverified
E2.3rotate or delete itthe key card · POST /api/v1/me/virtual-keys/{id}/rotatea new secret; the old one refusedverified

E3 — make the first call

#stepwhereexpectstatus
E3.1from the Playground, with no key handling at allPlayground (mints a 30-minute session key for the project)an answer, streamedbug — #1846, #1853
E3.2from code, OpenAI dialectOpenAI SDK with base_url=<gateway>/v1, api_key=<key>the same answer; x-request-id on the responseverified
E3.3from code, Anthropic dialectAnthropic SDK with base_url=<gateway>, the key as x-api-keythe same model family through /v1/messagesverified
E3.4streaming, embeddingsstream: true; /v1/embeddings on text-embeddingSSE chunks; vectorsverified

E4 — point a coding agent at rolter

#stepwhereexpectstatus
E4.1repoint Claude Code, Codex or Cursorthe third prompt in agent setupthe agent’s own traffic appears in LLM Logs under the engineer’s keyworks

E5 — “my calls fail: is it me, rolter, or the upstream?”

The debugging loop, in the order an engineer should be able to walk it.

#stepwhereexpectstatus
E5.1read the error the client gotthe response bodyOpenAI-style JSON whose type and code name the cause: 401 authentication_error; 403 model_not_allowed / route_not_allowed / provider_not_allowed / required_header_missing; 400 guardrail_blocked / plugin_blocked; 404 model_not_found (no such route); 402; 429 rate_limit_exceeded with Retry-After, or queue_full / queue_timeout; 503 queue_dropped; 502 pii_sanitizer_unavailable; other 5xx (no target, or the upstream)verified (401, 404 here; 403 in P1.2)
E5.2find the requestGET /api/v1/analytics/invocations?request_id=<x-request-id> (#1863); LLM Logs, filtered by status = error and modelthe row: status, target, provider, error text, latency, retries, and the captured bodiespartial — #1861 (LLM Logs has no request-id search; the API filter lands with #1863)
E5.3follow it into a tracethe row’s trace_id, copied from the LLM Logs drawer (#1983), in SigNozwhere the time went: queue, upstream, streamworks
E5.4check whether the upstream is sickCircuit Breaker, provider healththe provider behind the route: breaker state, uptime, recent failuresgap — #1833 (health needs an org-level role)
E5.5a call the client gave up onLLM Logs, status 499which target the client was waiting onbug — #1816
E5.6a call that is slow only under loadconcurrent calls to one routelatency stays flat as concurrency risesbug — #1815

E6 — “my MCP tool call failed”

#stepwhereexpectstatus
E6.1connect an MCP server that needs OAuthMCP Gateway → MCP Catalog, then consenta session of their own under Auth Sessionsworks
E6.2call a tool through the gatewaythe agent, via <gateway>/mcp/{server}the tool answersworks
E6.3see the failed tool callObservability → MCP Logsthe engineer’s own tool-call rowsgap — #1831 (superadmin-only)
E6.4a session expiredAuth Sessionsthe session’s state and a way to renew itworks

E7 — “my local MCP server misbehaves”

#stepwhereexpectstatus
E7.1see the raw JSON-RPC between the agent and a stdio server—every request, response and notification, live — the job tools like mcp-snoop do todaygap — #1832

E8 — “try my local model through rolter”

#stepwhereexpectstatus
E8.1point rolter at http://localhost:11434 for personal use—a route only the engineer’s keys reachgap — #1839 (an idea, not a plan)
E8.2the workaroundrolter easy-up on the workstationa personal gateway with the same API and logs, pointed at the local model through rolter.tomlworks

E9 — “what have I spent?”

#stepwhereexpectstatus
E9.1usage per key over the last weekSettings → My Virtual Keys (/api/v1/me/usage)requests and cost per keyverified
E9.2how much of my allowance is left—a personal budget and its remaindergap — #1830

E10 — make the dashboard mine

#stepwhereexpectstatus
E10.1set a display name and a short bio—other people see the name instead of an e-mailgap — #1823
E10.2language, default project, Playground modelthe language picker; the scope switcherremembered — but in this browser onlypartial — #1824
E10.3save “my errors this week” as a view—a named filter on LLM Logsgap — #1825
E10.4protect the account with a second factorSettings → My Virtual Keys → Two-factor authenticationTOTP and recovery codesverified

Application / service account

No person at all: a program holding a virtual key. A backend service, a batch job, a CI pipeline, an agent CLI running headless. It never opens the dashboard; the people who own it do (usually a team lead or an org admin). Its journey is what happens to its traffic, its limits and its credential over months.

Goal: “the service calls models through one endpoint; failures upstream are absorbed, limits are predictable, and rotating its key never takes it down.”

Dogfood setup: a shared key minted by lead@rolter.local in project default/default (step P1.1), used from curl or an SDK. See dogfood data.

The observer watches: request_logs for the key — status mix, retries, target spread, cost — and the gateway’s /metrics for queue depth and rejections while P5 runs.

P1 — get a credential that fits the job

#stepwhereexpectstatus
P1.1the owner mints a shared key for the serviceGovernance → Virtual Keys · POST /api/v1/projects/{id}/virtual-keysshown once; not tied to the person who minted it, so it survives them leavingverified
P1.2narrow it to the models and providers the service usesthe key’s model and provider allow-lists/v1/models with the key lists exactly those; anything else is refused, 403 model_not_allowedverified
P1.3attribute its spendthe key’s business unit and customerevery row the key serves carries both; Governance → Business Units and Customers roll them upverified
P1.4give it an expiry where the service is temporarythe key’s expiryrefused after the date, with an authentication errorverified

P2 — call models

#stepwhereexpectstatus
P2.1the unchanged OpenAI or Anthropic SDK, base URL swapped<gateway>/v1drop-in: no code change beyond the URL and the keyverified
P2.2correlate its own logs with rolter’ssend x-request-id, or read the one returnedthe same id on the request_logs rowverified
P2.3join its distributed tracesend traceparentrolter’s spans land in the service’s trace; the upstream receives the contextverified
P2.4keep a conversation on one replicasend x-session-idsession affinity on consistent_hash and pipeline routes; cache_aware follows the prompt prefix insteadbug — #1851

P3 — rotate its key without an outage

#stepwhereexpectstatus
P3.1rotate in place with an overlap window—a new secret on the same key, the old one honoured until the rollout endsgap — #1837
P3.2the workaroundmint a second key with the same settings, roll it out, delete the firstno outage — as long as every setting was copied by handpartial

P4 — be managed by automation

#stepwhereexpectstatus
P4.1a pipeline mints keys and routes for new customers—a control-plane token scoped to one project, audited as the pipelinegap — #1836 (only the superadmin admin token exists)

P5 — live with limits and failures

Run P5 against the dogfood fleet: its bad targets are what make the answers interesting.

#stepwhereexpectstatus
P5.1an upstream fails transientlydeepseek-r1 (vllm-spot-01 503s a quarter of the time)retried on the other target before the service sees it; no 5xx reached the client in the dogfood runverified
P5.2the service sends many requests at once16 concurrent calls to one routelatency stays near the upstream’s; the queue admits up to its worker count per providerbug — #1815 (one in flight per provider)
P5.3it outruns its rate limita rate limit on the keyHTTP 429 with Retry-After; the service backs offverified
P5.4it exhausts its budgeta budget on the key or its projectHTTP 402; nothing reaches the upstreamverified
P5.5the queue is fullprovider queue at capacityHTTP 429 queue_full / queue_timeout, depending on the backpressure policyworks
P5.6the service disconnects mid-requestclient timeouta 499 row, billed tokens kept, naming the target it waited onbug — #1816 (no provider on the row)

Finance / FinOps

Viewer at the org for reading, and — where the organisation lets finance set limits — admin at the org for budgets. Reads spend, never prompts: a viewer sees every request-log row of the org with the captured bodies withheld (#1820), which is exactly the separation a finance function wants.

Goal: “I know what we spent, on which models, for which business unit and customer; spend stops at the limits we agreed; and month-end is an export, not a project.”

Dogfood account: finops@rolter.local, viewer at org default. Steps that change a budget use orgadmin@rolter.local. See dogfood data.

The observer watches: which screen FinOps opens first, and how long it takes to answer “what did business unit X spend last month” — the answer should be one screen and one filter.

F1 — see the spend

#stepwhereexpectstatus
F1.1sign inlogin screenthe org’s Dashboard; every mutating control disabled with the role it needsverified
F1.2spend over time, by modelObservability → Dashboardtotals, a time series, per-model cost and latency — the org’s traffic onlyverified
F1.3read a request without reading its promptLLM Logs → a rowtokens, cost, status; the bodies say they are hidden for the viewer roleverified
F1.4spend by business unit and customerGovernance → Business Units, Customers · GET /api/v1/analytics/by-attributionone row per unit or customerverified
F1.5what business unit X spent last monthBusiness Units → window picker → Last month (#2107)the whole previous calendar month in the viewer’s time zone, dated under the total; kept in ?window= and carried to Customersworks

F2 — make the numbers trustworthy

#stepwhereexpectstatus
F2.1find traffic that counted as freeDashboard unpriced share, LLM Logs unpriced flagevery model with no price row namedverified
F2.2price itModels → Pricing Overridesrefused for an org admin: the price catalog is deployment-wide, a superadmin’sverified (by design); ask the platform team
F2.3decide what unpriced traffic does to a budgetthe budget’s unpriced policycount it as zero, or refuse itworks
F2.4keep a standing view of “spend by unit, this month”—a saved filter presetgap — #1825

F3 — set limits

#stepwhereexpectstatus
F3.1a monthly cap per business unit and per customerBudgets & Limits → Add budget (admin at the org) · POST /api/v1/budgetsHTTP 402 for the unit’s or customer’s keys once the cap is reachedverified
F3.2a cap per person—an allowance per engineer across their keysgap — #1830
F3.3change a capthe budget rowdelete and recreate: spend so far is kept, because counters are keyed by scopepartial

F4 — be told

#stepwhereexpectstatus
F4.1a warning at 80% of a budget—a notification before the 402gap — #337
F4.2an alert when spend velocity jumpsAlerting → Rules spend_velocityrefused below superadmin; deployment-wide onlygap — #1829

F5 — month-end

#stepwhereexpectstatus
F5.1export spend by unit and customer for the month—a CSV with the window, the currency and unpriced rows flaggedgap — #1838
F5.2the workaroundpage GET /api/v1/analytics/by-attribution with a session and convertthe same numbers, by scriptpartial

SecOps

Security engineering: reviews and hardens, rarely changes routing. Wants to prove, to themselves and to an auditor, who can read what, that secrets stay out of the logs, and that a leaver’s access ends. Today that needs superadmin, because every setting they review is deployment-wide and nothing narrower reaches it — which also hands them every tenant’s traffic.

Goal: “I can show who reads prompts, that credentials never land in a log or a response, and that access ends when it should — without being able to change the routing I am reviewing.”

Dogfood account: dev@rolter.local (superadmin), plus the other personas to test what they can read. See dogfood data.

The observer watches: audit_log — every step here either reads the audit trail or should leave a row in it.

S0 — the role

#stepwhereexpectstatus
S0.1review security posture without being superadmin—a read-only security-auditor role: settings, guardrails, audit, no tenant datagap — #1834

S1 — the perimeter

#stepwhereexpectstatus
S1.1confirm nothing runs openrolter check; the dashboard banneran admin token set; open mode refused on any non-loopback bind unless ROLTER_ALLOW_OPEN_MODE acknowledges it, as the local compose file does (#1890)verified
S1.2confirm an anonymous caller gets nothingthe #1820 sweep, or curl every GET in /openapi.json without credentials401 from everything the document does not mark publicverified
S1.3decide whether the redacted config stays publicGET /api/v1/config without credentialsno secrets, but every upstream api_base, route and grouppartial — #1840
S1.4restrict browser origins and ingressSettings → Security; CORSthe dashboard’s origin allowed, nothing elseverified
S1.5bound outbound requeststhe egress policya provider, connector or MCP server cannot be pointed at an internal address the policy forbidsverified

S2 — identity

#stepwhereexpectstatus
S2.1single sign-on only, second factor requiredGovernance → Single Sign-On → Org sign-in policyone button; accounts without a factor are refused until they enrolpartial — #1852
S2.2throttle password guessingrepeated wrong passwords for one accountHTTP 429 with Retry-After once the account’s allowance is spent; auditedpartial — #1854 (throttled; the audit row is unreadable)
S2.3a leaver loses everythingSCIM deprovision (platform-admin A1-d)sessions gone at once; personal keys refused at the gatewaybug — #1841
S2.4break-glass for a lost second factorrolter mfa reset --email … --reason … on the hostfactor and sessions cleared, the reason audited; not reachable over the APIverified (the audit row: #1854)

S3 — prove secrets stay out of the logs

#stepwhereexpectstatus
S3.1payload capture is off unless someone chose itObservability → Logs Settingsoff by default; the dogfood profile turns it on deliberatelyverified
S3.2known credential fields never reach storagepayload_capture_redact_fieldsthe value of a matching JSON key replaced before the write, recursivelyverified
S3.3a key pasted into a prompt never reaches storage—a secret inside free text redacted by patterngap — #1835
S3.4see what the redaction does before trusting it—a dry run against a sample bodygap — #1835
S3.5who reads the bodies that are storedsign in as each persona; LLM Logs → a rowmembers and admins of the project read them; viewers get “hidden for your role” unless the project allows itverified (#1820)
S3.6erase one subject’s logs on request—a subject’s rows and payloads deleted, auditedgap — #1085

S4 — inspection in the request path

#stepwhereexpectstatus
S4.1block a known-bad pattern before it reaches a providerGuardrails → Rulesthe request refused with guardrail_blocked: <rule>; the rejected text never storedverified
S4.2hand content to an external de-identification servicethe PII sanitizerthe provider sees the substituted text; optionally reversed on the way backworks

S5 — keys and recovery

#stepwhereexpectstatus
S5.1verify a restored database still opens its secretsrolter kek verifyevery sealed column opens with the current KEK, or the report names what failsverified
S5.2rotate the KEK without splitting the fleetbackup and restorethe documented rotation: no node is left unable to open a secret mid-wayworks

S6 — the trail

#stepwhereexpectstatus
S6.1who changed what, whenGovernance → Audit Logs (admin at the org)every mutation in this session’s scripts, with its actorverified for changes; sign-ins and second factors — #1854
S6.2a project was opened to its viewersthe audit log, project.settings.updatethe row, naming the value it was set toverified

DevOps / SRE

Maintenance only. Keeps the deployment healthy, upgraded, backed up and observable; drains a node before a change and restores a database after one. Has no reason to read prompts, mint keys or change who may do what — but today the screens they need are all superadmin-only, so they hold superadmin anyway.

Goal: “I know it is healthy, I can change it without an outage, and I can put it back if the change goes wrong.”

Dogfood account: dev@rolter.local (superadmin). See dogfood data.

The observer watches: the gateway’s /metrics and Cluster Config while D2–D4 run, and SigNoz rolter · overview for anything that got slower.

D0 — the role

#stepwhereexpectstatus
D0.1operate the deployment without superadmin—an operator role: cluster, health, runtime policy, alerting, connectors; no tenant data or grantsgap — #1834

D1 — stand it up and upgrade it

#stepwhereexpectstatus
D1.1installplatform-admin A0both planes, Postgres, Redis, ClickHouse healthyverified (compose)
D1.2reproduce an issue on a local copyjust dogfoodthe whole stack on one hostpartial — #1819 on hosts with a low nofile cap
D1.3preview an upgradeupgradingwhat the new version will change in the stored configuration, before it runsworks
D1.4upgradenew image; migrations run on control-plane bootthe new control plane opens its listener only once migrations finish, so neither probe answers until then; the chart’s startupProbe (5 minutes by default) holds liveness off meanwhile; the migrations check in /readyz only fails for a process serving a database it did not migrateworks

D2 — know it is healthy

#stepwhereexpectstatus
D2.1liveness and readiness/healthz, /readyz on both planesliveness never depends on the database; readiness doesverified
D2.2every node on the current configCluster Configeach gateway live and converged; a lagging one is distinguishable from a dead oneverified
D2.3the fleet’s own pictureCircuit Breaker, Adaptive Routing → Dashboard, provider healthbreaker states, per-target latency, adaptive engagementworks; dogfood adaptive never engages — #1817
D2.4metrics and traces in the tools the team already uses/metrics; Connectors for OTLP exportrequest rate, latency, errors, queue depth; traces in the team’s backendpartial — #1855 (no per-provider queue depth)

D3 — tune capacity

#stepwhereexpectstatus
D3.1size the per-provider queueSettings → Performance Tuningworkers requests in flight per provider; capacity waiting; error/block backpressurebug — #1815 (one in flight whatever workers says)
D3.2retries and timeoutsthe same screena retry budget that absorbs a flaky target without multiplying loadworks
D3.3switch subsystems on and off without a restartSettings → Feature Flagsthe change reaches every replica on the next config pollbug — #1856

D4 — handle an incident

#stepwhereexpectstatus
D4.1a provider goes downstop a fleet targetits breaker opens, its routes fail over, health shows the outage and the recovery (MTTR)works
D4.2clients time outLLM Logs, status 499which target the clients were waiting onbug — #1816
D4.3be pagedAlerting → Channels / Rules: error_rate, p95_latency_msa webhook when the error rate or p95 crosses the lineverified (created; firing not exercised)
D4.4take a node out before touching itCluster Config → Drainthe node reports not-ready, finishes in-flight work, receives nothing newverified (refuses to drain the only live gateway)

D5 — back up and restore

#stepwhereexpectstatus
D5.1back up the control-plane databasebackup and restorea dump plus the KEK, stored apartworks
D5.2restore it and prove the secrets still openrolter kek verifyevery sealed column opensverified
D5.3keep the configuration as a file toorolter config exportproviders, groups, routes, prices, templates; no credentialsverified

D6 — automate

#stepwhereexpectstatus
D6.1apply configuration from CI with a least-privilege credential—a scoped control-plane tokengap — #1836 (the admin token is superadmin)
D6.2apply it from a filerolter-seed --importdesired state for the sections it importspartial — #1818 (silent about the rest)

Viewer

Viewer at a project, a team or the org. A stakeholder, product manager, support engineer or auditor: reads the dashboard, the request log and the configuration, changes nothing that belongs to anyone else. Two things are the viewer’s own, and the product should let them manage both: their account (profile, preferences, second factor) and their view of the data (the filters they keep coming back to).

Goal: “I can see what I am responsible for, in the shape I need it, and keep that shape — without being able to break anything.”

Dogfood account: viewer@rolter.local, viewer at project default/default. finops@rolter.local is an org-level viewer for comparison. See dogfood data.

The observer watches: refused_click in ui_events. A viewer reaching for a disabled control is fine once — reaching for it twice means the screen did not say clearly enough that it is read-only.

V1 — see the project

#stepwhereexpectstatus
V1.1sign inlogin screenthe dashboard for project default/defaultbug — #1846
V1.2traffic, spend and latencyObservability → Dashboardthe project’s numbers onlyverified
V1.3a request in detailLLM Logs → a rowstatus, tokens, cost, target; the bodies say they are hidden for the viewer roleverified
V1.4the bodies too, where the project allows ita project admin turns on Viewers can read captured payloadsthe same row now shows the request and the responseverified (#1820)

V2 — own the account

#stepwhereexpectstatus
V2.1set a display name and a bio—others see the name; the bio says who to ask about whatgap — #1823
V2.2enrol a second factorSettings → My Virtual Keys → Two-factor authenticationTOTP and recovery codes, same as any roleverified (as a member, E10.4)

V3 — preferences

#stepwhereexpectstatus
V3.1switch the dashboard languagethe language pickerthe whole dashboard in Russian — remembered in this browser onlypartial — #1824
V3.2land on the right scope on a new laptopthe scope switcherthe last scope, from the server rather than localStoragepartial — #1824

V4 — read the configuration without changing it

#stepwhereexpectstatus
V4.1which models, routes and strategies existModel Catalog, Routing Rulesreadable; every edit, delete and toggle disabled, naming the role it needsbug — #1846 (no project in scope, so the screen refuses)
V4.2which keys exist and what they may reachGovernance → Virtual Keysnames, prefixes, allow-lists; never a secretbug — #1846
V4.3which budgets and limits applyBudgets & Limitsreadable, not editablebug — #1846
V4.4who may do whatGovernance → Roles & Permissionsthe published matrix, including this session’s analytics, request_payload, provider_health and project_settings rowsbug — #1846 (the API publishes all four rows)

V5 — keep my own view

#stepwhereexpectstatus
V5.1save “errors on gpt-4o this week” as a named filter—one click back to it on any browsergap — #1825
V5.2the saved filter is mine alone—nobody else sees it; it never widens what I can readgap — #1825

Development setup

Prerequisites

  • Rust (stable) via rustup — the workspace pins the toolchain in rust-toolchain.toml.
  • Bun for the UI, at the version .bun-version pins (CI reads the same file) — from the repo root, curl -fsSL https://bun.sh/install | bash -s "bun-v$(cat .bun-version)".
  • prek for repository Git hooks — install with uv tool install prek or brew install prek.
  • Docker + Compose for Postgres/Redis/ClickHouse.
  • uv (optional) for the PyPI-wheel install path and tooling.

Clone & build

git clone https://github.com/rolter-ai/rolter.git
cd rolter
cargo build --workspace
cargo nextest run --workspace   # or `cargo test --workspace`; install: cargo install cargo-nextest

Run the gateway (no external services needed)

cp rolter.example.toml rolter.toml
export OPENAI_API_KEY=sk-...        # referenced by api_key_env in the config
cargo run -p rolter-gateway -- --config rolter.toml
# -> http://localhost:4000  (/healthz, /metrics, /v1/*)

Run the control plane + UI

cargo run -p rolter-control          # http://localhost:4001
cd ui && bun install && bun run dev  # http://localhost:3000 (proxies /api -> :4001)

Run the full stack

docker compose -f docker/docker-compose.yml up -d                 # postgres, redis, clickhouse, gateway, control

ClickHouse asks for 262144 open files, its production recommendation. A container runtime cannot grant more than its own hard limit, and rootless Docker or Podman, sandboxed CI runners and locked-down VMs often have less. There the ClickHouse container is never created:

OCI runtime create failed: runc create failed: unable to start container process:
error during container init: error setting rlimits for ready process:
error setting rlimit type 7: operation not permitted

Lower the limit with CLICKHOUSE_NOFILE. 20000 is plenty for a dev stack (#1819). Put it in docker/.env (gitignored), so every later compose call renders the same limit:

echo CLICKHOUSE_NOFILE=20000 >> docker/.env
docker compose -f docker/docker-compose.yml up -d

It has to be docker/.env, not a .env at the repository root. docker compose -f docker/docker-compose.yml takes its project directory from the first -f file, so it interpolates from the shell environment and from docker/.env only; a root .env is read only when you pass --env-file .env. The just recipes run compose the same way, so docker/.env covers them too.

A one-off prefix such as CLICKHOUSE_NOFILE=20000 just dogfood also works, but only for that command. The next compose call without it (just up, or just signoz-reset after a failed SigNoz provisioning) renders 262144 again, and compose recreates the ClickHouse container at that limit, where the runtime refuses it.

The same variable covers SigNoz’s ClickHouse in docker/docker-compose.signoz.yml and the e2e stack’s. The e2e compose file lives in integration/e2e/, so its persistent spot is integration/e2e/.env.

Handy tasks

just wraps the common commands:

just build | just test | just fmt | just lint
just gateway | just control | just ui-dev | just up

Build these docs

This book is mdBook. Diagrams are ```mermaid fences rendered by the mdbook-mermaid preprocessor, so both tools have to be on PATH — with mdbook alone the build still succeeds and every diagram silently ships as a plain code block.

cargo install mdbook mdbook-mermaid --locked
just docs         # build to docs/dev-docs/book/ (gitignored)
just docs-serve   # live-reloading preview on http://localhost:3001

The mermaid runtime is vendored at docs/dev-docs/mermaid.min.js and docs/dev-docs/mermaid-init.js so the book renders air-gapped. mdbook-mermaid install docs/dev-docs regenerates both, but mermaid-init.js carries a local fix — upstream still binds theme buttons by their pre-0.5 ids (ayu, navy, …), which now throws and leaves diagrams on the light palette after a theme switch — so re-apply it after any refresh. Write labels in mermaid’s own dialect rather than GitHub’s — quote any label containing / (R["/v1/responses"], since [/…] is parallelogram syntax) and break lines with <br/>, never \n.

Before committing

cargo fmt --all
cargo clippy --workspace --all-targets -- -D warnings
cargo nextest run --workspace && cargo test --doc --workspace   # or `just test`
prek install --prepare-hooks
prek run --all-files
prek run --all-files --hook-stage pre-push

The clippy line above only builds the default feature set, so it cannot see a lint that fires under one feature combination alone — dead_code on a helper whose only caller is #[cfg(feature = "otlp")], for instance. CI closes that gap in the rust lint job, whose cargo hack (each feature) step runs cargo hack with RUSTFLAGS=-D warnings. Reproduce a failure from it with:

cargo install cargo-hack
RUSTFLAGS="-D warnings" cargo hack check --each-feature --workspace --all-targets

The hooks add staged-file hygiene and secret scanning, Conventional Commit validation, Rust/workflow/TOML/spelling checks, workspace tests, dependency policy checks, and UI lint/build checks. Install the system tools used by the project-specific hooks:

brew install actionlint taplo typos-cli
cargo install cargo-nextest cargo-deny

cargo-nextest is recommended but optional for the push hook; it falls back to cargo test. CI remains authoritative for database-backed tests that need ROLTER_TEST_DATABASE_URL; to run them locally, eval "$(just test-pg)" starts the machine’s shared test Postgres and exports the variable (see testing.md).

When the hooks skip Rust

The rust-tests, cargo-deny and cargo-clippy-postgres push hooks, and the cargo-fmt and cargo-clippy commit hooks, run through scripts/prek-rust-gate.sh, which skips them when the change touches no Rust input — so a dashboard- or docs-only push does not wait on a cold --all-features build of the whole workspace (#1486). A push counts as touching Rust when any changed, added, deleted or renamed path is one of:

  • anything under crates/, any *.rs, any Cargo.toml or Cargo.lock
  • rust-toolchain(.toml), .cargo/, .config/deny.toml, .config/nextest.toml
  • prek.toml and the two scripts/prek-rust-*.sh scripts
  • the files a Rust test reads from outside its crate: rolter.example.toml, docs/dev-docs/development/stability-markers.md and ui/src/lib/nav.tsx

A push hook inspects the range prek hands it (PRE_COMMIT_FROM_REF...PRE_COMMIT_TO_REF). A commit hook gets no range, so it passes --staged and the gate reads the index (git diff --cached) instead. Whenever there is nothing to inspect — prek run --all-files, an empty index, a push with no remote ancestor, a ref that does not resolve — the hooks run in full, and ROLTER_PREK_RUST_ALWAYS=1 forces them. The gate is a script rather than a files = or types = filter because prek drops deleted paths before matching, so a change that only removes a .rs file would otherwise skip the fmt, clippy and test runs it can break (#1526).

When a Rust test starts reading a new file from outside crates/, add the path to the list in scripts/prek-rust-gate.sh and here. The hook only saves local time: hosted ci-ok runs the full suite on every pull request regardless.

Testing

Run

Tests run under nextest (the same runner CI uses), plus a separate doc-test pass since nextest does not run doc tests:

cargo nextest run --workspace   # unit + integration tests
cargo test --doc --workspace    # doc tests
cd ui && bun run lint           # ui typecheck: src, scripts, .storybook, e2e

Install the runner once with cargo install cargo-nextest (or see the nextest install docs). just test runs both Rust passes for you. Plain cargo test --workspace still works if you haven’t installed nextest, but CI runs nextest so prefer it locally.

The Ollama Cloud live smoke sends a billed request and is ignored by default:

OLLAMA_API_KEY=... ROLTER_OLLAMA_LIVE_MODEL=gpt-oss:20b \
  cargo test -p rolter-gateway --test ollama_cloud live_smoke -- --ignored

The Gemini Interactions smoke is gated the same way. It exists because Google publishes no full JSON schema for the interactions wire format, so parts of the adapter — the multimodal part field names and some step.delta variants — were inferred from prose docs and only a real request confirms them (#764):

GEMINI_API_KEY=... ROLTER_GEMINI_LIVE_MODEL=gemini-3.6-flash \
  cargo test -p rolter-gateway --test gemini_interactions_live -- --ignored

Both run in CI only from dispatch-gated workflows, never the per-PR gate: quality.yml takes no secrets by design (#734) so dependabot and fork PRs pass exactly the same checks. Assertions in the live suites carry the upstream response body in their failure message — with an inferred field name, the provider’s complaint is the finding, and a bare status-code assertion would throw it away.

Configuring the Gemini smoke

gemini-interactions-smoke.yml needs one secret before it can verify anything:

  1. Create a live-providers repository environment (Settings → Environments). Keeping the key there rather than at repository scope means a run against a billed provider is reviewable, not something any workflow can reach for.
  2. Add GEMINI_API_KEY to that environment.
  3. Dispatch the workflow (gh workflow run gemini-interactions-smoke.yml), optionally with -f model=<id>.

Until the secret exists the workflow fails rather than skipping. A green tick from a run that made no request reads as “the wire format is still confirmed” when nothing was checked — worse than no sweep at all. Pass -f allow_unconfigured=true for a deliberate dry run of the workflow itself.

Each run records the wire shapes it observed into the job summary and uploads the full log as an artifact. A billable run should leave evidence behind: the next question about a field name is then answered by reading the last run rather than by spending another call.

What the suite covers, and why each probe exists:

ProbeConfirms
text turnturn mapping, system_instruction, generation_config, usage — all documented
inline image partthe inferred mime_type/data inline part shape
remote image partthe inferred file_uri shape — the other branch, which the inline probe never reaches
tool call round tripfunction_call out, function_result back, and call_id correlation
interaction threadingthe id rolter surfaces as the response id is the one Google accepts back
every client dialectChat Completions, Messages and Responses have separate response translators
every client dialect, streamingthe inferred step.delta variants, through all three separate SSE emitters

A content part the dialect cannot carry is rejected at the gateway with 400 unsupported_content_part rather than being dropped (#882), so an unconfirmed part shape fails loudly instead of producing a shortened body.

Test grouping is configured in .config/nextest.toml: the Postgres-backed rolter-store/rolter-control suites share one database and reset the schema per test, so they run in a single-threaded group to avoid clobbering each other.

The Postgres test database

The Postgres-backed tests self-skip unless ROLTER_TEST_DATABASE_URL points at a Postgres they may write to. A machine needs one such server however many worktrees it has, and just test-pg is the way to get it:

eval "$(just test-pg)"   # starts rolter-test-pg on 127.0.0.1:55433 if needed, exports the url
cargo nextest run -p rolter-store -p rolter-control --features postgres

The recipe is idempotent: it creates the rolter-test-pg container the first time, starts it again after a reboot, and otherwise only prints the export line, so every session on the machine runs the same command and lands on the same server. just test-pg-status shows the connections in use against max_connections and which worktree each test database belongs to; just test-pg-down removes the container together with its data. ROLTER_TEST_PG_PORT and ROLTER_TEST_PG_MAX_CONNECTIONS pick a different port or limit, and only take effect when the container is created, so change them with a just test-pg-down first.

Do not start a Postgres per worktree. Each worktree already gets a database of its own on the shared server (below), so a second container isolates nothing, and nothing ever takes it down: it outlives the worktree that started it, holding a port, shared memory and disk, and the next session has to step over it to find a free port (#1736). Containers left over from that habit (rolter-test-pg-<n>, rolter-<n>-pg and similar) can go once nothing uses them.

Any other Postgres works too, as long as the role may create databases (CREATEDB), since each worktree gets one of its own; without it the tests still run, they just share the database the url names.

One database per worktree

ROLTER_TEST_DATABASE_URL names the server, not the database the tests end up writing to. The database is derived from the workspace the test binary was compiled in — rolter_test_wt_<worktree>_<digest> — and created on first use by rolter_store::postgres::test_database. A new worktree is therefore isolated without exporting anything, which is the point: this repository expects several agents working several worktrees at once (see worktrees.md), so concurrent suites against one database is the normal case rather than an edge case, and an isolation scheme that has to be opted into is forgotten exactly when it is needed (#1430).

Two things that used to be true stop being true:

  • a suite in another worktree no longer creates and drops test_* schemas underneath the one you are measuring — the 396 foreign schemas that appeared mid-session while #1364 was being verified
  • a worktree on an older commit no longer applies its migration set to the database another worktree is reading

The worktree’s path is stored as the database’s comment, and both ways a database is reclaimed go through it. Removing the worktree with wt remove runs the pre-remove hook in .config/wt.toml, which drops the databases carrying that worktree’s path on the just test-pg server straight away. Independently of any hook, the next test run in any worktree drops every rolter_test_wt_* database whose recorded directory no longer exists, so a worktree removed some other way, or one whose tests ran against a different server, still takes its database with it. List them with just test-pg-status, \l rolter_test_wt*, or:

select datname, shobj_description(oid, 'pg_database')
from pg_database where datname like 'rolter_test_wt%';

Set ROLTER_TEST_PER_WORKTREE_DATABASE=0 to use ROLTER_TEST_DATABASE_URL exactly as given — a throwaway database that is already private, or a deliberate reproduction of the shared-database behaviour. The derivation also steps aside when it cannot create a database (a role without CREATEDB, for instance): it prints why and falls back to the configured url, because losing isolation is better than losing the suite.

The connection budget

A database per worktree isolates schemas and migrations, but not the server’s max_connections: every suite on the machine, from every worktree, draws from that one budget (#1735). When it runs out, Postgres refuses new connections with sorry, too many clients already and the refusal lands on whichever test happened to be connecting.

Measured with the rolter-store and rolter-control postgres suites run the way plain cargo test runs them (every test in a binary at once, one thread per logical CPU by default), each simulated worktree on a database of its own, and client backends sampled every 100 ms:

Worktrees × test threadsPool per testmax_connectionsPeak client backendsOutcome
1 × 81010014green
6 × 81010078green
4 × 2410100100, the limit978 refused connections, 79 of 560 tests failed
4 × 2410300120green but one flake of the sweep test (#1910)
4 × 244300114green
1 × 241300273 tests fail on the pool: they need 2

A test holds about one connection at a time, so a worktree costs roughly one connection per test thread plus a handful for the harness. The pool size barely moves the peak; the number of tests running at once does. That is why the stock limit of 100 holds six worktrees on an 8-thread laptop and fails four on a 24-thread workstation. Under cargo nextest the serial-db group in .config/nextest.toml runs one postgres test at a time per worktree, so a worktree holds only a few connections there; the numbers above are the case for cargo test, and for nextest too if that group goes (#1429).

So the budget is kept in two places:

  • just test-pg starts the server with max_connections = 300: room for about ten worktrees at 24 threads, and twenty at 8. A slot nothing is connected to costs a little shared memory, not a backend, and the larger lock table that comes with it (max_locks_per_transaction is per slot) also gives the schema drops more room. On a server of your own, size max_connections the same way: worktrees × (test threads + 5)
  • TestSchema builds each test’s pool with at most 4 connections instead of the control plane’s 10: twice what the hungriest tests use (a KEK rotation holding a transaction while it queries, the readiness probe), and a test that wants more waits for a free connection instead of opening one. It is a ceiling on the worst case rather than the fix. ROLTER_TEST_POOL_MAX_CONNECTIONS overrides it

A test that cannot connect says which budget ran out, since the pool on its own does not: it retries too many clients until its 30-second acquire timeout and then reports a bare pool timed out while waiting for an open connection, which reads like a slow query or a regression. TestSchema connects directly for its own setup. It waits out a server at its limit for the same 30 seconds, with backoff, since a slot usually frees within milliseconds as another test’s guard finishes its cleanup, and only then panics with the server’s answer (has no connection slots left (SQLSTATE 53300 ...)). A server that is down is reported at once instead, as nothing is accepting connections. A test that fails while the server is at or within a tenth of its limit gets the same note printed after its panic, and one that fails with its own pool fully checked out is told that instead. just test-pg-status shows how many connections are in use; cargo test -- --test-threads=<n> lowers a worktree’s share when the server cannot be changed.

One schema per test

Each test gets a schema of its own, named test_<pid>_<seq> and pinned through search_path, because plain cargo test — which the coverage job runs — puts every test in one process as a thread, and a shared public schema would race on DDL. Build it through rolter_store::postgres::test_schema::TestSchema rather than by hand; other crates reach it through the store’s test-support feature, which rolter-control already carries as a dev-dependency.

Hold the guard for the whole test. The schema is dropped when TestSchema is, so a binding let go early takes the tables with it:

#![allow(unused)]
fn main() {
let db = TestSchema::migrated(&url).await;   // guard lives to the end of the test
let pool = db.pool().clone();
}

Cleanup runs from Drop, which also runs while a panicking test unwinds, so a failing test reclaims its schema too. Two cases still leave residue, and both are handled by a sweep the first TestSchema in a process performs:

  • a process killed hard — SIGKILL, a nextest timeout, a laptop losing power — never runs Drop at all
  • runs predating this mechanism (before #1364) never dropped anything

The sweep drops every test_<pid>_<seq> schema whose pid is not a live process, in batches: each schema carries the full migration set, and dropping thousands in one transaction runs the lock table out of shared memory. It is deliberately one-sided — a schema whose pid is live is always kept, so a suite running concurrently in another process can never lose its schema, and a pid the operating system has recycled only defers a drop to a later run.

So the database needs occasional attention rather than none: a crash-heavy afternoon can leave schemas behind until the next run reclaims them, and a database that has not been used for tests since #1364 landed still carries whatever earlier runs orphaned. Check what is there with:

select count(*) from information_schema.schemata where schema_name like 'test\_%';

Schemas from the older helpers used seed_* and export_* names with no pid in them, so the sweep cannot prove they are dead and leaves them alone. Nothing creates those names any more, so drop them once by hand — in batches, for the same lock-table reason:

do $$
declare victim text;
begin
  loop
    select schema_name::text into victim
    from information_schema.schemata
    where schema_name like 'seed\_%' or schema_name like 'export\_%'
    limit 1;
    exit when victim is null;
    execute 'drop schema ' || quote_ident(victim) || ' cascade';
    commit;
  end loop;
end $$;

One schema per statement is deliberate. A migrated schema holds around 210 relations and cascade locks every one of them, so even ten schemas in a single transaction exhausts the lock table — the out of shared memory the issue describes is reachable at far fewer schemas than it sounds.

When a failing test’s rows are the evidence, set ROLTER_TEST_KEEP_SCHEMA=1: the guard then keeps every schema it creates (printing each name) and skips the sweep, so nothing is reclaimed until you drop it yourself.

What keeps all of this honest

Three rules hold the isolation together, and crates/rolter-store/tests/db_test_isolation.rs fails the build when a new test breaks one — the same shape of source-level drift guard as the gateway’s lock_discipline.rs:

  • the database comes from test_database::url(), never from ROLTER_TEST_DATABASE_URL read directly
  • the schema comes from TestSchema, never from a hand-rolled create schema
  • no test in these suites installs a process-wide environment value only it wants — the one exception is the shared TEST_KEK every call site in control_integration.rs installs, which exists precisely so the race has nothing to observe (#1351)

That last rule is what cargo test -p rolter-control -p rolter-store --features postgres used to break (#1444): the environment is process-wide and the coverage job runs every test in a binary as a thread, so a value one test wanted was read by another test’s in-flight request. Pass it into the app under test instead — test_app_with_public_url is the model.

The Redis test server

The tests that exercise the gateway’s Redis connections — budgets, rate limits, the response cache and the reconnecting connection they share — self-skip unless ROLTER_TEST_REDIS_URL names a Redis they may use. A throwaway container on a free port is enough:

docker run -d --rm --name rolter-test-redis -p 127.0.0.1:6390:6379 redis:8-alpine
ROLTER_TEST_REDIS_URL=redis://127.0.0.1:6390 cargo nextest run -p rolter-gateway
docker rm -f rolter-test-redis

The url names the server; any database path on it is ignored. Each test selects a logical database of its own (the db constants in crates/rolter-gateway/src/redis_conn.rs), because the reconnect tests close connections with CLIENT KILL and must only close their own: the kill targets the clients that have that test’s database selected, so parallel tests — and the nextest process-per-test model — never cut each other’s connections. Keys carry a per-run suffix and are deleted afterwards, and nothing is flushed, so a shared development Redis is safe to point at. A new Redis-backed test picks the next unused number there; there are 16 databases by default.

The outage-and-restart tests do not touch the server at all: they put a TCP forwarder (testing::Outage) in front of it and take that down and back up.

The nextest / doctests and coverage jobs in quality.yml run a Redis service and set the variable, so these tests run in CI rather than passing as silent no-ops.

Layout

  • Unit tests live next to the code in #[cfg(test)] mod tests. Current coverage: balancer strategies (round-robin cycling, consistent-hash stability, cache-aware affinity, empty targets), the prefix trie, config parsing, model rewrite, auth checks, and the in-memory store.
  • Keep the pure crates (rolter-core, rolter-balancer, rolter-auth) fully unit-testable without I/O.

Strategy as the project grows

  • Integration tests for the gateway: spin up the Axum app with a mock upstream (wiremock/httpmock) and assert routing, auth, model rewrite, error mapping and streaming passthrough.
  • Property tests (proptest) for the balancer: distribution fairness, affinity invariants.
  • DB tests for rolter-store Postgres backend behind a feature, using a disposable container.
  • Load tests (oha/k6) against a mock upstream to track added latency and max RPS (see performance.md).

Chaos & resilience contracts

Resilience is asserted in two places, split by what each harness can drive deterministically.

The e2e chaos suite (integration/e2e/tests/test_chaos.py, compose chaos profile) drives a static-config gateway against mock upstreams whose failure mode is fixed by an env var. It covers what is observable purely over the wire: retry and failover on 5xx/429, a clean 5xx when every target is down, the request timeout bound on a slow upstream, the circuit breaker’s OPEN transition (asserted via rolter_breaker_opened_total) and flap degrade/recovery.

The gateway chaos tests (crates/rolter-gateway/tests/chaos.rs) cover the two contracts that need a request pinned at a known point inside the gateway, which a black-box harness can only approximate with sleeps:

  • bounded-queue backpressure — with queue.capacity = 1, queue.workers = 1 and backpressure = "error", one request pins the sole worker, exactly one surplus request takes the queue slot, and the rest must come back as 429 with error.code = "queue_full" while rolter_provider_queue_rejections_total advances. Memory is bounded by the queue, not by client burst size.
  • graceful SIGTERM drain — a real rolter-gateway child process is sent SIGTERM while a request is pinned upstream. The in-flight request must still return 200, new connections must be refused, and the process must exit 0.

Both use a mock upstream that blocks on a semaphore the test owns, so every step is driven by a signal rather than by elapsed time — there are no sleeps to race. Run them with:

cargo test -p rolter-gateway --test chaos

Benchmarks

Hot-path micro-benchmarks run under criterion. They live in crates/<crate>/benches/ with a [[bench]] harness = false entry per file, and cover the per-request cost that shows up as pure gateway overhead:

just bench                       # cargo bench --workspace
cargo bench -p rolter-balancer   # just the balancer benches
cargo bench -p rolter-balancer --bench pick   # one bench target

Current coverage:

rolter-balancer

  • pick — LoadBalancer::pick for every built-in strategy over a ~24-target pool with a populated RouteContext.
  • trie — prefix-trie insert (bounded/unbounded, so LRU eviction is measured) and longest_prefix on a warm trie.

rolter-core

  • snapshot — the CPU side of config-snapshot generation at 10/100/1000 routes: sanitize_for_snapshot, validate, and the JSON encode. /internal/snapshot is polled by every gateway in the fleet, so this cost is paid fleet-wide on every poll. The encode dominates — ~2.8 ms at 1000 routes against ~150 µs for sanitize — which is why payload size is its own metric (#845).

rolter-gateway

  • admission — the two registries every upstream attempt consults before anything else: Breaker::allows and Cooldowns::is_parked, plus the outcome-recording calls beside them. Covers the healthy steady state (no entries recorded), a warm fleet, a tripped/parked target and a 64-model fleet, so the cost is measured where a real gateway actually sits rather than only in the worst case (#1050).

criterion writes HTML reports to target/criterion/. Benches are not run in CI (timings are noisy on shared runners), but cargo clippy --workspace --all-targets -- -D warnings compiles them on every PR, so they cannot silently bit-rot. Use just bench-check (cargo bench --workspace --no-run) to compile them locally without running.

Coverage

Workspace line coverage is measured with cargo llvm-cov:

cargo install cargo-llvm-cov
cargo llvm-cov --workspace --all-features --summary-only   # quick %
cargo llvm-cov --workspace --all-features --html           # browsable report

The coverage job runs a different runner

Everything else runs under nextest, which gives each test its own process. cargo llvm-cov shells out to plain cargo test, so the coverage job runs the whole suite as threads in one process sharing one environment. Two rules follow, and both have bitten:

  • Never set a process-wide environment variable to a value only your test wants. Kek::from_env() is read at request time, so a test that installs its own ROLTER_KEK is read by another test’s in-flight request, and a value sealed under one key then fails to open under the next. The symptom lands on whichever unrelated seal-then-open test was mid-flight, never on the test that caused it. control_integration.rs installs one shared TEST_KEK for exactly this reason (#1351); a test needing a non-matching key builds it with Kek::from_secret rather than through the environment.
  • Postgres tests must use a per-test schema (search_path), since this job shares one database across concurrently running tests.

A comment saying “runs in its own process (nextest)” is true of every job except this one, which is what makes the trap easy to walk into.

CI runs coverage in the coverage job of quality.yml on every pull request (and on no other event), and again nightly on master in extended.yml, whose run also saves the Rust cache the PR job restores under the shared key coverage. Both enforce a ratcheting baseline: the committed baseline lives in .github/coverage-baseline.txt, and .github/scripts/coverage-ratchet.sh fails the step if the current percentage drops more than COVERAGE_TOLERANCE points (default 0.5) below it. The job also uploads the lcov.info report as a CI artifact.

Policy (ROL-246):

  • New code must not push coverage below baseline − tolerance. If a PR legitimately lowers coverage, edit .github/coverage-baseline.txt in the same PR and explain why.
  • When coverage climbs well above the baseline, raise the baseline to lock in the gain (the ratchet only goes up).
  • The PR job is informational (continue-on-error: true) until the baseline is trusted; promote it to blocking by removing that flag on the coverage job in quality.yml. The nightly copy carries no such flag, so a ratchet failure on master opens the tracking issue described below.

CI

.github/workflows/ci.yml delegates to the shared quality.yml gate, which runs cargo fmt --check, cargo clippy -D warnings, cargo nextest run --workspace --all-features plus a cargo test --doc pass, the feature matrix (cargo hack), cargo doc (warnings as errors), the publish verify build, the gateway smoke, cargo-deny, gitleaks, the zizmor workflow audit, and the UI lint/build on every push and PR. ci.yml’s ci-ok job then checks the pull request itself: the title is one valid Conventional Commit line, and neither the body nor, on a dispatched or merge-queue run, the commit range carries a coding-agent session url (see the ci-ok gate).

The static checks job

The checks that read the tree and build nothing run as steps of one job, static checks (static in quality.yml): gitleaks over the working tree and the branch history, the session-url check over the PR’s commits, migrations append-only, typos, taplo, cargo-deny, unused deps, actionlint, zizmor, the release handoff checker, its self-test and the release gate scripts’ fixture test, the board automation retry policy, and the helm chart’s appVersion check, lint and three renders. Until #2025 each was a job of its own. They did 0-15 s of work apiece and then waited a median 86-200 s for a runner, since every job a push starts draws on the same 20 concurrent slots. The decision and its trade-offs are in the CI runner budget ADR.

The merge changed how a failure looks, not what can fail:

  • Every step runs under !cancelled(), so a typo and a taplo drift in the same push fail as two steps, each named after the job it used to be. A step keeps its old job’s event guard: the branch-history gitleaks pass runs on pull requests and merge-queue runs, the session-url commit check on pull requests only.
  • A step that reads a tool the job installs (taplo, cargo-deny and cargo-machete from taiki-e/install-action, uv, helm) is guarded on that install as well, so a failed download shows up as a skipped check rather than as command not found under the check’s name.
  • The gitleaks steps run before any setup step, so the working-tree scan never sees a tool unpacked into the workspace.
  • The last step, report, writes every step’s outcome to the job summary and emits one error annotation per failed step, titled with the former job’s name and carrying the command that reproduces it locally, usually the prek hook of the same name (prek run typos --all-files). It also fails the job when a step that must run on the event was skipped. A skipped step reads as a pass, and that is how ci-ok once went green without reading a commit message (#1562).
  • The job times out after 20 minutes. It does about 90 s of work, and a hung step would otherwise hold every other check’s verdict for GitHub’s 360-minute default.

The report is the one place that knows which step must run on which event. A check added to the job therefore needs three things: its step, an OUTCOME_* line in the report’s env, and a row call in the report’s script. A step without a row runs unreported, and a row whose step id is misspelled reads an empty outcome, which the report counts as a failure.

The rust lint and rust build jobs

The Rust checks outside nextest run as steps of two jobs, split by whether they link. rust lint holds fmt, clippy (default features and postgres), cargo doc with warnings as errors, cargo hack over each feature and the cross-crate feature combination. None of them invokes the linker, so the job skips the wild linker. rust build holds the publish verify build (cargo package plus maturin sdist), the gateway smoke build and probe, and last the three advisory semver-checks steps. Until #2025 these were six jobs: fmt / clippy, feature matrix, cargo doc (warnings = errors), package (publish verify), gateway smoke (fake-llm) and semver-checks (advisory).

They follow the rules of the static checks job above: every check step runs under !cancelled() and is guarded on the setup it reads, and a report step titles each failure with the former job’s name and the command that reproduces it. Every blocking step must run on every event, so a skip fails the job. Two things differ:

  • The flags that used to be job env are step env now. RUSTDOCFLAGS sits on the cargo doc step. RUSTFLAGS=-D warnings sits on the two cargo hack steps, which build into target/hack, because a different RUSTFLAGS would otherwise rebuild the clippy steps’ dependency tree on every run. rust build sets no RUSTFLAGS at all: the variable replaces every rustflags entry in cargo’s config, the wild linker’s --ld-path included.
  • The semver steps are continue-on-error, the install and the baseline lookup included, and the semver check has a 20-minute step limit. The report turns their failure into a warning. ci-ok never goes red over the semver check, as ADR-0032 requires; see API stability.

rust lint times out after 30 minutes and rust build after 45, so a hung build fails the gate well before GitHub’s 360-minute default.

The Rust cache is saved from master only

Every Swatinem/rust-cache step in quality.yml, ci.yml, extended.yml and engine-integration.yml sets save-if: ${{ github.ref == 'refs/heads/master' }}. A pull request run restores the cache its job last saved on master and writes nothing back, and so do a release-PR dispatch, a merge-queue run and a workflow_dispatch on a branch. Only the master push run and the nightly extended.yml run, which GitHub starts on master, save.

A cache belongs to the ref that saved it. One saved on master is visible to every pull request; one saved on refs/pull/<n>/merge is visible to that pull request alone. Every PR and the release-PR branch used to save its own copy of each Rust job’s target directory, roughly 0.1-0.9 GB apiece. On 09-29 the cache held 40 entries and about 14.4 GB against the repository’s 10 GB limit, two thirds of it on PR and release-PR refs, and GitHub evicts the least recently used entries until the total fits, master’s included (ADR-0034).

The cost lands on the PR that changes Cargo.lock or the toolchain. A lockfile change misses master’s exact key, so rust-cache restores the entry saved for the old lockfile and the job rebuilds what changed, on every push until the PR merges. A toolchain change alters the key’s environment hash and starts cold. The first master push after the merge saves the new cache. The nextest / doctests job carries a 30-minute timeout with that in mind: warm runs take 6-10 minutes, so a cold build fits inside the limit, and a hung test no longer holds the gate for GitHub’s 360-minute default.

A new rust-cache step takes the same save-if line. Only gemini-interactions-smoke.yml goes without it: it runs on its weekly schedule, which already runs on master.

engine-integration.yml runs only on path-filtered pull requests and on dispatch, never on a master push, so a cache of its own would never warm. Its smoke builds the same cargo build -p rolter-gateway as rust build, so it restores that job’s cache instead: both steps set shared-key: rust-build, and the smoke installs the wild linker too, because the linker’s rustflags are part of every unit’s fingerprint and a build without them would reuse nothing it restored (#2203). Keep the two jobs’ toolchain, linker and key in step. To see what the cache holds:

gh api repos/rolter-ai/rolter/actions/cache/usage
gh cache list -R rolter-ai/rolter --sort size_in_bytes --limit 50

The rustdoc gate is the one CI check nothing local reproduces

cargo doc (warnings = errors), a step of rust lint, is the gate that most often turns a locally-clean branch red, because cargo fmt, cargo clippy and cargo nextest are all silent about it. Run it before pushing:

RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps

The usual failure is rustdoc::private_intra_doc_links: a public item whose doc comment links [Something] that is private. It is easy to write, because explaining why a public type exists usually means naming the internals it wraps — and it is invisible until CI says so. Either unlink it (a plain code span reads the same) or make the target public if it deserves to be.

UI dependencies and the lockfile

The ui, storybook, docs job installs with bun install --frozen-lockfile, for everyone — dependabot included. That was not always true, and the reason it is now is worth recording.

ui/ is bun-managed, but dependabot could not speak bun, so the repository kept a package-lock.json purely for dependabot’s benefit and ran the npm ecosystem against it. A bump moved package.json and package-lock.json and left bun.lock untouched, which fails a frozen install — so the install self-healed with --no-frozen-lockfile whenever the actor was dependabot.

That kept dependabot’s own PR green without making its merge safe. The moment such a bump landed, every other open PR installed frozen against the new package.json and failed in eight seconds with lockfile had changes, but lockfile is frozen — a red check on work that had touched no dependency, which is exactly the kind of failure that trains people to re-run without reading (#1137, reconciled by hand in #1086 and again in #1136).

Dependabot has spoken bun since bun 1.1.39, so /ui now runs as package-ecosystem: bun and package-lock.json is gone. Dependabot writes bun.lock itself, so a stale lockfile fails on the PR that caused it and never reaches master. The trade is that the bun ecosystem does version updates but not security updates, and GitHub’s dependency graph reads ui/package.json but not bun.lock. Alerts still fire for the dependencies ui/package.json declares, and the pull request that fixes them comes from the workflow below instead of from Dependabot. A package that only arrives transitively through bun.lock raises no alert at all: every transitive ui alert was marked fixed the moment package-lock.json was deleted, with no version having changed. Nothing monitors that class until #1930 audits the lockfile itself, and #1931 tracks the vulnerable transitive packages bun audit reports today.

UI security updates

.github/workflows/ui-security-updates.yml stands in for the Dependabot security updates the bun ecosystem lacks (#1148). GitHub’s supported-ecosystems table still marks bun as version updates only; once that changes, delete the workflow, its script and the .github/actionlint.yaml entry.

It runs daily at 06:30 UTC and on demand. Each run:

  1. lists the open Dependabot alerts with the repository GITHUB_TOKEN, under the vulnerability-alerts: read scope. A failed listing fails the run rather than reading as “no alerts”;
  2. hands them to ui/scripts/security-updates.ts, which raises each vulnerable direct dependency in ui/package.json to its first patched release and keeps the range operator (^1.2.0 becomes ^1.2.7);
  3. refreshes bun.lock with bun install --lockfile-only;
  4. rebuilds the security/ui-dependencies branch from master when the planned change differs from what the branch already carries, and opens or updates one pull request, titled build(deps): raise vulnerable ui dependencies to patched releases;
  5. dispatches ci.yml against that branch after every push, since a pull request opened with the repository token triggers no pull_request run. This is the same path the release PR takes (see ci-gating).

The script only raises what it can raise safely. It judges each alert on its own, raises a package to the highest first patched release among the alerts a semver-compatible bump clears, and leaves the rest for a hand bump. Each one is listed, with its reason, in the run summary and the pull request body:

  • no patched release has been published yet;
  • the package is not a direct dependency (the fix would be an overrides entry, which changes what every other dependent resolves);
  • the range is not a plain ^, ~ or exact version;
  • the first patched release is a breaking bump from the current floor (a new major, or a new minor below 1.0). One such advisory does not hold back a compatible fix for another advisory on the same package;
  • the range already starts at the patched release, so the alert is stale and closes on its own once GitHub re-reads the manifest.

The pull request carries station:mac, since the mac station owns ui/, and that station reviews and merges it like any other of its PRs. A pull request the repository token opens raises no event that project-automation.yml fires on, so the workflow dispatches it with the new pull request’s number and area=ui right after gh pr create, which puts it on the board as In Review (#2245). A failed dispatch only warns and names the command to run by hand.

Each proposal body ends with a hidden marker naming the ranges it raises and their targets. Closing the pull request unmerged declines that exact set of bumps: while a closed, unmerged pull request for the branch carries the marker of today’s plan, the workflow does not open another. The decision reads pull request state, never the branch, so an unrelated dependency change on master does not reopen a declined bump, while a new alert or a different target does.

When nothing is left to raise, the workflow withdraws its own pull request. It first rewrites the body to say why, with the table of anything still left for a person and without the marker, so a withdrawal never counts as a person declining, then closes the pull request and deletes the branch.

The run heals itself when a step fails half way:

  • a push that landed before gh pr create failed leaves a branch with no pull request and no declined one, and the next run opens the pull request for it;
  • when the branch already carries the planned change and ci.yml has no run for its head that is in progress or ended in success or failure, the workflow dispatches one. A cancelled or never-dispatched gate is re-run the next day; a failed one is left for a person to read and re-run.

To try a change to the workflow before it merges, dispatch it with dry-run from the branch. A dry run writes the plan to the run summary and pushes nothing. A run that publishes refuses to start anywhere but master.

gh workflow run ui-security-updates.yml --ref <branch> -f dry-run=true

The publish path only runs from master, and with no open alert it has nothing to publish. The synthetic-alert input exercises it on purpose: it adds one made-up alert (number #0, advisory SYNTHETIC, linking to the run) on a direct dependency, patched at the version you name, which must be a real release above the current floor and semver-compatible with it. After a change to the publish or dispatch steps merges, run it once from master:

gh workflow run ui-security-updates.yml -f synthetic-alert=@opentelemetry/api@1.9.1

Then check that the pull request opened with station:mac, that the dispatched ci.yml run reported ci-ok on its head, and that the next run without the input withdrew it and deleted the branch. Merge nothing from a synthetic run.

The planner runs locally against a saved alert listing:

gh api "repos/rolter-ai/rolter/dependabot/alerts?state=open&ecosystem=npm" > alerts.json
cd ui && bun scripts/security-updates.ts --alerts ../alerts.json   # plan only

actionlint has no entry for vulnerability-alerts yet (rhysd/actionlint#713), so .github/actionlint.yaml ignores that one message in that one file. Any other permission typo in the workflow still fails the check.

Secret scanning

The two gitleaks steps of the static checks job run the gitleaks CLI from a digest-pinned container, not gitleaks-action. The action gates org-owned repositories behind a license key, and license secrets are invisible to both dependabot runs (a separate secret store) and fork PRs (no secrets at all), so every such PR failed the check and with it ci-ok. The CLI is free and unrestricted, so quality.yml now takes no secrets and behaves identically for forks, dependabot and direct pushes.

Two passes run with the shared .github/config/gitleaks.toml policy: gitleaks dir over the working tree (everything the commit ships) and, on PRs and merge-queue runs, gitleaks git --log-opts base..head over the branch history. That second pass catches a secret added and then removed inside the same PR; on a queue run the range is every commit the queue is about to write to master (see ci-gating). Both passes run before any setup step, so the working-tree scan sees the checkout and nothing a later step unpacked into it. The pinned digest is v8.30.1 — the version prek.toml already uses for the staged-content hook, so local and CI scans agree.

Reproduce a CI run locally:

docker run --rm -v "$PWD:/repo" -w /repo \
  ghcr.io/gitleaks/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f \
  dir . --config .github/config/gitleaks.toml --redact --exit-code 1

Workflow security (zizmor)

The zizmor step of the static checks job audits .github/workflows/ and .github/actions/ for workflow security smells — unpinned or impostor action refs, template injection into run:, over-broad GITHUB_TOKEN scopes, cache poisoning, dangerous triggers. It is a merge gate (#1456): a finding fails quality, which fails ci-ok.

It ran as informational (continue-on-error: true) until the baseline was clean. That state is what the promotion is a reaction to: a regression to 34 findings went unnoticed precisely because nothing failed on it (#1325), and an action pinned to an unreachable commit (#1227) was waved through as noise on every PR for weeks. A check nobody has to fix is a check nobody reads.

The gate runs at --min-severity=medium --persona=regular, the setting the baseline was proven clean against. Reproduce a CI run locally:

uvx zizmor@1.26.1 --min-severity=medium --persona=regular \
  .github/workflows/ .github/actions/

Some audits query the GitHub API (impostor-commit, stale-action-refs, known-vulnerable-actions), so export a GH_TOKEN — or pass --offline to skip them, which is enough for a quick check but is not what CI runs.

Retrying an API hiccup, but never a finding

Those online audits are also the gate’s one source of flake. A single 401, 5xx or rate-limit answer from the GitHub API aborts the whole run with fatal: no audit was performed, so a PR that touched no workflow goes red for a reason that has nothing to do with it. While the job was informational that was invisible; now that it blocks, it is indistinguishable from a real finding. So the step retries, up to three attempts with a short backoff (#1509).

What makes the retry safe is that zizmor’s exit code already separates the two cases, so the retry never has to infer which one it is:

ExitMeaningGate behaviour
0audit completed, nothing to reportpass, first attempt
11–14audit completed, findings at informational…highfail, first attempt
1no audit was producedretry, but only when the output names a GitHub API failure
2, 3bad arguments / no inputs collectedfail, first attempt

A completed run is a verdict, and a verdict is final immediately — a genuine finding can never be retried away because it never reaches the retryable branch. Only exit 1 is a candidate, and then only when the output matches no audit was performed, request error while accessing GitHub API or couldn't list tags; an internal zizmor bug fails fast instead of costing three runs. Exhausting the three attempts is a failure, never a silent pass — the same rule ci-ok applies to its own run listing: no answer is not an answer.

Why known-vulnerable-actions still gates every PR

known-vulnerable-actions checks pinned actions against zizmor’s advisory database, so alone among the audits its verdict can change while the repository does not: a newly published advisory against an action we pin turns every open PR red at once, for something none of those PRs did. That is the same shape as a fresh RUSTSEC advisory, which is exactly why cargo audit runs on a schedule from audit.yml rather than on each PR.

It stays in the blocking PR gate anyway, and deliberately. zizmor has no flag that disables a single audit — the only granularity is --no-online-audits, which would also drop impostor-commit and stale-action-refs, and those two are precisely the audits that catch a bad action ref introduced by the diff in front of you. Trading away the audits that read the current diff to pre-empt an advisory that has not fired yet costs more than it saves, and the remedy in the noisy case is to bump the pin — the change we would want to make regardless, on whichever PR notices first. Revisit this if zizmor gains per-audit selection or if the advisory case starts actually costing PR time; a scheduled zizmor --min-severity=medium job in audit.yml, beside cargo audit, is the shape that split would take.

Fix a finding rather than silencing it. Where a finding is genuinely a false positive for this repository, suppress that one rule on that one step with # zizmor: ignore[<rule>] and put the reason in a comment directly above it — a bare suppression is indistinguishable from the noise this gate exists to stop. The four suppressions in the tree today are:

WhereRuleWhy
engine-integration.yml — Swatinem/rust-cachecache-poisoningnothing this workflow builds is published, so the cache cannot poison a release
release-plz.yml — both actions/checkout stepsartipackedrelease-plz pushes the release branch and the tags with the persisted token, so persist-credentials must stay on
project-automation.yml — pull_request_targetdangerous-triggersrequired so fork PRs can read the org PAT; the workflow never checks out PR head and passes only the project id, the item’s node id and url, and literal field names to run:, all through env:

Storybook play tests

The ui, storybook, docs job builds the static Storybook, serves it, and runs the interaction (play) tests with @storybook/test-runner against a headless chromium. It is a merge gate (#753): a failing play test fails quality, which fails ci-ok. Locally:

cd ui
bunx playwright install --with-deps chromium chromium-headless-shell
bun run test:stories                            # every story file
bun run test:stories src/pages/Keys.stories.tsx # or just these

Why test:stories rather than the two commands by hand

storybook dev -p <port> does not fail when the port is taken. It logs Starting..., exits, and leaves whatever was already listening in place — another worktree’s Storybook, or a stale python3 -m http.server --directory storybook-static from an earlier session. test-storybook --url http://localhost:<port> then runs against that server and reports a green suite for a build that never contained the stories under test. Nothing in the output says so. This has happened twice in one day (#1648, #1684), and both times the only thing that caught it was fetching /index.json by hand.

bun run test:stories (ui/scripts/run-story-tests.ts) closes that hole:

  • it picks a free port itself, or fails when the one passed to --port is taken. The probe connects rather than binding — python3 -m http.server and Bun.serve both set SO_REUSEADDR, so a second bind on a squatted port succeeds and a bind-only probe calls it free
  • it starts storybook dev --ci and waits for /index.json
  • it checks that index against the story files it was asked to run: the file must be indexed under its own import path, and every export const … : Story in it must be present. A Storybook that is not this project fails here, and so does a stale build of a file whose newest story is missing
  • it identifies the process holding the port. The index check above compares content, so another worktree of this same repository sails through it — its build indexes the same story ids under the same import paths. That is the case that actually happens here, and it did, on port 6032 (#1693). So the guard reads the listening pid with lsof -ti :<port> and asks for its working directory (lsof -a -p <pid> -d cwd -Fn): storybook dev is spawned with its cwd in this worktree’s ui/, so a listener rooted anywhere else — a sibling worktree, or a process whose cwd lsof will not disclose — fails the run. On a machine with no lsof the check is skipped with a warning rather than failing; the index check still applies
  • only then does it run the tests, one file per invocation — the positional pattern is passed through /bin/sh, so a pattern containing (, | or ) dies with a shell syntax error

Running the two commands by hand still works and is sometimes what you want (driving the same server through several runs, say). If you do, check /index.json for your story ids first; that is the whole difference between a green run and a meaningless one.

Under the hood it is still:

bun run build-storybook
python3 -m http.server 6006 --directory storybook-static &
bun run test-storybook --url http://127.0.0.1:6006

ui/package.json pins playwright and playwright-core through overrides. The test-runner declares its own loose playwright range, so without the pin it resolves a different version from @playwright/test and launches a browser revision playwright install never downloaded — the test-runner then fails at launch and the play tests silently stop running (#737). Keep both on one version, and install chromium-headless-shell alongside chromium, since the test-runner launches the shell rather than the full build.

The static build is the one that matters. storybook dev serves modules unbundled and answers from a warm cache, so it is consistently faster than the build this job runs — fast enough to hide a story that is racing something. A focus assertion is the usual victim, which is what #1675 was:

await waitFor(() => expect(canvas.queryByRole("dialog")).toBeNull());
await expect(canvas.getByRole("button", { name: "open" })).toHaveFocus();  // ✗

A dialog, drawer or sheet takes focus from an effect a step after it enters the document, and gives it back from that effect’s cleanup a step after it leaves. The second line above has no retry, so it passes only while the handover lands inside the incidental gap between the two statements. Deferring the restoration by 600ms makes it fail outright, with Received element with focus: <body>.

Wrapping it is the whole fix:

await waitFor(() => expect(canvas.getByRole("button", { name: "open" })).toHaveFocus());

bun run check:focus (ui/scripts/check-story-focus.ts) fails on the unwrapped shape and runs in the ui, storybook, docs job, so this cannot reach a PR again. An assertion straight after .focus(), a userEvent call or another expect(…) needs no waiter — those have already settled where focus is, and the check allows them.

Note that #1672’s configure({ asyncUtilTimeout: 5000 }) does not cover this. That budget is how long waitFor and findBy* are willing to wait; an assertion that never polls waits zero milliseconds however high it is set. The two are orthogonal.

The job carries no continue-on-error, so it blocks: a failing story fails quality, which fails ci-ok. (This paragraph used to say the opposite — continue-on-error: true was removed when the job was promoted in #753 and the line was left behind.)

What userEvent.tab() cannot see (#1998)

While a dialog, sheet or the nav drawer is open, useModalA11y makes everything outside it inert. user-event does not know that attribute: it works out where Tab goes from a selector of its own, which skips disabled controls and tabindex="-1" but not inert ones, and then calls .focus() on the result, which the browser refuses. A story that presses Tab from <body> over an inert page therefore watches focus stay on <body>, which proves neither the trap nor the inert page.

So a modal story asserts the two halves apart. It waits for focus to be inside the panel before it tabs, since from there the panel’s own trap answers the key, and it checks the page behind directly:

await waitFor(() => expect(dialog).toHaveFocus());
await userEvent.tab();
await expect(dialog).toContainElement(document.activeElement as HTMLElement);
await expect(trigger.closest("[inert]")).not.toBeNull();

A pointer has the same blind spot. A synthetic click is dispatched straight to its target, so it still “works” on an inert element, while a real one passes through it as if it had pointer-events: none. Where a story needs to know that a real pointer lands on a scrim, it asks document.elementFromPoint(x, y), which does honour inert. ui/src/lib/modal-a11y.stories.tsx has both shapes.

A fixed overlay is measured by its box (#2003)

expectNoHorizontalOverflow compares the document’s scroll width with the window’s, which is the right question for a screen and the wrong one for a dialog or a sheet. Both are position: fixed, so they add nothing to the document’s scroll width, and the page behind them has its scrolling locked. A Save button past the edge of a full-screen sheet measures clean while nobody can press it.

So an overlay story measures the control itself with expectInViewport(el) from ui/src/lib/story-viewport.ts: the whole box has to be inside the window. The helper first waits for every animation that ends, because a sheet slides in from the right edge for 240 ms and a box read the moment the panel appears is wherever the slide has got to. Looping animations such as spinners are skipped.

On the screen is not always the same as on the sheet. A footer that overflows toward the right can still end a few pixels inside a 375 px window while it sits in the sheet’s gutter. ModelSheet’s phone stories therefore also compare the primary action’s right edge with the header’s close button.

The widths and heights these stories run at come from the same module, spread at story level: atMobile (375×812), atTablet (768×1024) and atShort (640×360, a 1280×720 screen at 200 % zoom, the size WCAG 1.4.10 asks content to reflow at). A story that also needs the Russian catalog merges the two globals: globals: { ...atMobile.globals, locale: "ru" }.

Stories are drawn in the fonts the app ships (#2051)

Geist and Geist Mono are vendored through fontsource, and the one place that imports the packages is ui/src/lib/fonts.ts. Both ui/src/main.tsx and .storybook/preview.ts import that module. Until #2051 the preview imported only index.css, so every story fell through to the browser’s fallbacks: a Courier-like mono, and in ru a serif for the “мс” unit in mono cells, because that fallback has no Cyrillic. Overflow and truncation stories measured those metrics, and screen reviews judged type the product never ships.

The faces are font-display: swap, so a face is fetched only when text first asks for it and the fallback is drawn until it arrives. The preview’s beforeAll therefore loads every face of --font-sans and --font-mono before the first story renders, and a story that measures text sees Geist’s metrics from its first paint.

Storybook builds its story store only after that hook resolves, so the test runner’s preVisit in .storybook/test-runner.ts awaits __STORYBOOK_PREVIEW__.ready() before it reads a story’s context. Without the wait, a slow font load made getStoryContext read the store too early, and every story in that suite failed with SB_PREVIEW_API_0011 (#2275). A new async step in the preview gets the same protection as long as it runs inside beforeAll.

Two checks keep it that way. Behaviour/Fonts (ui/src/lib/fonts.stories.tsx) asserts in en and ru that each character of a sans sentence and a mono latency has a loaded Geist face covering it and is measured differently from the fallback, which fails when the preview loses the import. ui/src/lib/fonts.test.ts fails when either entry stops importing lib/fonts.ts, when any other file imports a fontsource package directly, or when a package’s family is not the first one the tokens name. A new face goes into lib/fonts.ts, never into main.tsx.

How long a story waits (#1279)

.storybook/preview.ts calls configure({ asyncUtilTimeout: 5000 }), which raises testing-library’s default from one second for every findBy* and waitFor in every story.

The default is a unit-test budget: it assumes the thing being awaited is a render. A screen story is not that — it mounts a page that resolves org, then team, then project, then its own endpoints, each one a fetch through the stub and a react-query transition. On an idle machine that chain lands in a couple of hundred milliseconds, which is why every one of these stories passes when it is the only file running. Under the full parallel run it does not always: #1279 caught Screens/Rbac timing out on a branch that touched no RBAC code, with the failure dump showing the screen still on its tab header — the assertion was right and the data was still in flight.

The budget is a ceiling on how long a failing assertion waits, never a delay a passing one pays, so the suite does not get slower. Prefer it over a per-assertion { timeout }: a timeout written at one first-paint assertion is a timeout the next story will not have. The exceptions are the few places that genuinely need longer than the shared budget, such as expectLoadError in ui/src/pages/story-harness.tsx, which has to outlast a screen’s own retry policy and says so beside the number.

To check whether a story is racing rather than broken, add a delay to scoped() in the harness, rebuild, and re-run the file — and restart the static server after every rebuild, since a server left running over a replaced storybook-static keeps serving the build it started with.

A raised budget only helps an assertion that retries. getByRole and a bare expect do not: they read the DOM once, so they wait zero milliseconds at any budget and pass only while the stub answers inside the same tick. That is what #1689 was — three plays acting on a control whose data is a request behind the surface it sits on:

  • a sheet fires its own query as it opens, so its rows are not there the instant the dialog is. await form.findByRole(…), never form.getByRole(…) on the line after the sheet opened
  • a gated control renders enabled until /api/v1/rbac/effective answers — undefined is “not known yet” and only an explicit false disables — so expect(button).toBeDisabled() asserted once is reading the gate before it spoke. Use expectRefused, which waits for the disabled flag and the title together
  • and toBeDisabled() on its own passes for the wrong reason whenever the control is also disabled by its own form state (an empty key, an invalid draft). The title is what tells a refusal apart from a draft

And the mirror image is worse rather than equal (#1707). A gated control renders enabled before the answer lands, so toBeEnabled() on it is true from the first paint: it is satisfied on the first poll, a waitFor around it changes nothing, and it cannot fail at any latency — including against a control plane that 404s the endpoint and leaves every capability unknown. There is no state change to wait for, which is why the fix is not a waiter but expectAllowed.

expectAllowed(canvasElement, name, role?) waits on the harness’s own gate probe first: Harness renders a hidden data-gate span inside the CapabilityProvider whenever a story carries a role, reading answered only once the effective-permissions query has settled with a payload. Only then is the control read — enabled, and carrying none of the refusal sentences, so a control disabled by its own form state cannot pass for a permitted one. The probe is the harness’s and never the dashboard’s: no production component learns a test-only attribute.

bun run check:waits (ui/scripts/check-story-waits.ts) enforces all three shapes, in the same ui, storybook, docs job as check:focus and on the same grep-level terms:

  • a toBeDisabled() outside a waiter, in a story whose harness carries a role — the only case where a gate is in flight at all
  • a toBeEnabled() in such a story, waiter or not. It is the one rule here a waitFor does not satisfy, because the state it asserts is the state the control starts in
  • a getByRole("checkbox" | "radio" | "row" | "cell" | "option" | …) on the statement right after sheet() or a findByRole("dialog")

The second rule looks only at roles a screen renders one of per row of fetched data. A heading or a confirm button is part of the sheet’s own markup and paints with it, and flagging those would mean a waiver on every editor story — which is how a guard gets switched off. getByLabelText is out for the same reason: a sheet’s fields are in its first paint.

A case the rule is genuinely not about carries // story-wait-allow: <reason> in the comment block above the line, the way check:primitives takes its waiver. There are three in the tree today, and every run prints them:

// story-wait-allow: disabled by its own prop from the first paint, so there
// is no gate answer to wait for - that it stays disabled is the point
await expect(within(canvasElement).getByRole("button")).toBeDisabled();

One thing the check deliberately does not see, and which a reviewer still has to: a data query more than one statement after the sheet opened.

Every story is also an axe test

postVisit in ui/.storybook/test-runner.ts runs axe-playwright over the whole document once the play function has finished, and fails the story on any violation at any impact — minor and moderate included. The rule set is wcag2a + wcag2aa + best-practice, and every disabled rule is named in DISABLED_RULES with the reason beside it. The whole document rather than #storybook-root, because dialogs, sheets and toasts portal to <body> and those are the ones worth checking.

The moderate/minor band, measured

The gate shipped as serious+critical only (#1181); #1244 measured what the other half contained before turning it on. Over all 695 stories:

ruleimpactnodesstoriesdecision
regionmoderate3177488off by default — page-level
landmark-one-mainmoderate593593off by default — page-level
page-has-heading-onemoderate570570off by default — page-level
empty-table-headerminor1313fixed
heading-ordermoderate1111fixed
landmark-uniquemoderate99fixed

The three page-level rules all describe a page. A story normally mounts one component, or one screen body, into a bare iframe with no app shell around it: the landmarks, the <main> and the <h1> those rules ask for live in App.tsx and components/ScreenHeader.tsx. Asserting them on a component story would only ever fail, and satisfying them would mean every story grew a fake shell that ships nowhere — so DISABLED_RULES turns them off for the default case.

They are off by default, not unchecked (#1353). Two story files mount a whole page and turn them back on by name:

Story fileWhat it mountsWidths
ui/src/App.stories.tsxthe assembled shell — rail + header + screen, signed in (#1239)1280, 768, 375
ui/src/pages/Login.stories.tsxthe signed-out login page, which has no shell around itdesktop

Both spread withPageA11y from ui/src/lib/story-a11y.ts into their meta parameters; postVisit merges parameters.a11y.rules over DISABLED_RULES, so an override is per-story and additive and cannot loosen the gate for anything else. Spread it into parameters, never as a bare story field — the JSDoc docgen transform appends a parameters: { docs: … } of its own to every meta and would replace a whole-object spread silently, leaving the story green and unchecked.

Two guards, because prose did not hold (#1373)

The first version of the override above shipped as ...withPageA11y at meta level and ran green asserting nothing; it was caught by printing the merged rule map by hand. A gate that can be switched off without a word is the one failure worth spending code on, so the placement rule is now enforced twice:

GuardWhereWhat it catches
parameters.a11y.expectRules.storybook/test-runner.ts postVisitthe fixture did not arrive. withPageA11y carries the rule ids it claims to enable; the runner fails the story if any of them is not enabled in the map it actually merged. A story whose id matches PAGE_A11Y_STORY_ID (shell-app--*, screens-login--*) is held to the three rules whether or not it carries the claim, so losing the fixture entirely — claim and all — still fails
bun run check:storiesui/scripts/check-story-parameters.ts, run by bun test scriptsthe spread is in the wrong object, before Storybook is even built. It reads src/lib/story-*.ts and sorts each exported fixture by shape: one with its own parameters key (atMobile, atTablet) must be spread at story level, one without (withPageA11y) must be spread inside parameters

The shapes are read from the fixture modules rather than listed in the checker, so a fixture added later is covered the day it is written. A third story file that mounts a whole page needs its title added to PAGE_A11Y_STORY_ID in ui/src/lib/story-a11y.ts; story-a11y.test.ts fails if the pattern and the files that spread the fixture disagree.

Moving the spread up one level fails like this:

screens-login--wrong-password: axe rules region, landmark-one-main,
page-has-heading-one should be enabled for this story but are not. spread
`withPageA11y` from src/lib/story-a11y.ts *inside* the meta's `parameters`
object (`parameters: { ...withPageA11y }`) — spread as a bare story or meta
field it is replaced by the docgen transform and the story passes asserting
nothing (#1373).

parameters: { a11y: { disable: true } } still opts a story out of the axe gate entirely, the expectRules check included — that is one explicit, reviewable line, which is the opposite of the silent drop these guards exist for.

That is what a separate @axe-core/playwright pass in ui/e2e/ would have bought, for a fraction of the cost: no new dependency, and it runs on every PR with the rest of the story gate rather than only where Playwright does. The one fix it asked for was Login.tsx, whose card is now a <main>.

The three fixed ones were small and real: an empty <th> over the actions column in Cluster and User provisioning (now an sr-only common.rowActions), an <h4> under an <h2> in Single sign-on, and the two unnamed <aside> landmarks on Prompt repository and Skills repository.

Re-measure any time — set ROLTER_AXE_TALLY to a file path and the runner appends one JSON line per story listing every violation at every impact, including the excluded rules, without failing anything:

ROLTER_AXE_TALLY=/tmp/axe.jsonl bun run test-storybook --url http://127.0.0.1:6011

The failure prints the story id, then two tables: the rule and its impact, then the CSS selector and the HTML of each offending node. color-contrast also names the measured foreground, background and ratio, which is usually enough to pick the right token straight from Dashboard theme without opening a browser. Narrow a run to one screen by naming its file: bun run test:stories src/pages/Keys.stories.tsx.

A story can opt out with parameters: { a11y: { disable: true } } and a comment saying why. None currently does — treat needing one as a signal that the screen, not the checker, is wrong. The inverse, parameters: { a11y: { rules: { … } } }, re-enables a rule DISABLED_RULES turns off; it is for stories that mount a whole page, and the two that do are listed above.

The screen-story harness

A screen story renders the real page component against a stubbed fetch, so it exercises the same query wiring, empty/error branches and editor sheets that ship. There is no shared mock module: each screen’s fixtures live in its own .stories.tsx, built from these stubs, and a browser test that needs real rows seeds a running control plane through ui/e2e/seed.ts instead. ui/src/pages/story-harness.tsx holds the shared pieces — it is not a .stories.tsx file, so Storybook never tries to render it as a screen:

HelperWhat it is for
Harnessswaps globalThis.fetch, clears the persisted scope, renders under a fresh QueryClient with retry: false
scoped(handler)answers the org → team → project chain every scoped screen resolves first, then defers to handler
routes([...])fragment-matched routing table, matched in order so a longer path can precede the prefix it shares
pendinga stub that never settles, for the loading state
json(body, status)a JSON Response, with no body for 204/205/304 so a success stub cannot throw
recording(handler)wraps a stub and keeps every call, so a story can assert the method, URL and body that actually left
clickWhenEnabledwaits for a button to be enabled, not merely present
sheet() / expectSheetClosed()the editor sheet, which portals to document.body rather than into the canvas
withConfirm / expectClosesWithoutPromptingthe discard guard from #868, asserted in both answers

Two traps this encodes. Scope endpoints are matched on the whole pathname: a screen’s own endpoint often contains one of them (/api/v1/projects/{id}/virtual-keys), and a substring match would answer it with the project list. And most screens disable their primary action until the three-request scope chain resolves, so findByRole followed by a click races and throws pointer-events: none — clickWhenEnabled is the fix.

Each screen should carry Loaded, Loading, Empty and an error/forbidden story, one interaction story that opens the primary editor and saves, and at least one story exercising the discard guard. Where a sheet opens pre-filled (budgets seed 100 / monthly), assert the seed too: its dirty flag means “differs from the seed”, not “is non-empty”, and getting that backwards makes an untouched form prompt on every close.

Components carry stories on the same terms

Not only screens: every component under ui/src/components/ has a sibling .stories.tsx, and the states it can be in are what the stories enumerate — a sheet gets add/edit, saving, rejected and both answers to the discard guard; a primitive gets its variants plus whatever contract it promises (that Field labels its control, that Table keeps its column headers over an empty body, that a scroll container is reachable from the keyboard).

Two habits that the run enforces:

  • Wait for the first read. A component that seeds itself in an effect (ParamsEditor, ModelSheet, ProviderGroupSheet) has not seeded yet when the play function starts — Storybook does not render inside act. Open with findBy*/waitFor, or the assertion races the mount, and an edit typed before the seed lands is overwritten by it.
  • A stub the story reads back must be created once. Building a recording() in a useMemo keyed on a prop whose identity changes per render hands every render a fresh recorder, and the requests the last one saw are thrown away.

The axe pass over the new stories found three real defects rather than story bugs, which is the argument for the gate: ModelSheet’s collapsible section headers were a div role="button" wrapping the info hint’s own button (nested-interactive), ProviderGroupSheet’s member weights were labelled by title alone (label-title-only), and StatusRow’s colorText painted its label with the solid --status-* signal colour, which is below AA as text — the --status-*-text pair exists for exactly that.

A StrictMode story mounts its subject in a later commit (#1744)

The obvious way to test React.StrictMode behaviour in a story is to wrap the subject in <React.StrictMode> inside render. That story renders the subject twice, but it mounts each effect once and never runs a cleanup, so an assertion about the simulated unmount and remount passes against broken code. The first StrictMode* story in ui/src/components/EditorSheet.stories.tsx did exactly that in #1743: it stayed green against the naive fix it was written to refuse.

React decides what to double after each commit. It walks down from the root to every newly placed fiber and runs the simulated unmount and remount on it only if the walk passed a StrictMode element on the way, or the placed fiber is one (recursivelyTraverseAndDoubleInvokeEffectsInDEV in react-dom-client.development.js, React 19.3). Storybook mounts every story as <ErrorBoundary key={storyId}><Story /></ErrorBoundary> (renderToCanvas in @storybook/react), so the placed fiber is that boundary. It sits above the story’s own StrictMode, the walk stops there, and nothing below it is doubled. Double rendering follows a different rule, the fiber’s mode, which is why the renders still come in pairs and the story looks strict.

So mount the StrictMode first and the subject into it later. The host renders the subject only when its own state says so, render starts it without one, and the play function mounts it with a click:

export const StrictModeDoesNotInventAnAbandon: Story = {
  render: () => (
    <React.StrictMode>
      <Unmountable mounted={false} />
    </React.StrictMode>
  ),
  play: async () => {
    // mounts the sheet in a later commit, under a StrictMode that is already there
    await userEvent.click(screen().getByRole("button", { name: "open the editor" }));
    // the double-invoke has happened by the time the sheet can be used
  },
};

This is also the app’s own shape. ui/src/main.tsx makes the root strict long before anyone opens a sheet, so a story built this way runs the same lifecycle a browser on bun run dev does.

Two more habits keep such a story from passing for the wrong reason:

  • Anchor an absence on something that happened. “No form_abandon” is also true before the sheet has finished mounting, so expectNoUxEvent(…) on the line after the click proves nothing. StrictModeDoesNotInventAnAbandon first presses the sheet’s save button and waits for its form_submit: the button cannot be pressed until the sheet has mounted, been remounted and settled, so the absence asserted after that point covers the whole double-invoke.
  • Watch it fail once. Break the code the story guards and run the file with bun run test:stories; for #1739 that meant emitting the abandon straight from the effect cleanup. A StrictMode story that stays green against the broken code is asserting against a lifecycle that never ran. When the reason is unclear, a probe settles it: a child whose effect counts its mounts and cleanups should read two mounts and one cleanup under a working StrictMode, and reads one and zero in the same-commit shape.

framework.options.strictMode in ui/.storybook/main.ts would put a StrictMode above that boundary for every story. It is off, and turning it on changes the lifecycle of every story in the tree, which is a larger decision than one assertion needs.

Nightly extended checks

.github/workflows/extended.yml holds the informational checks that need a full build and gate nothing. It runs nightly at 01:41 UTC and on workflow_dispatch, rather than on every push, so none of them takes a slot from the 20-job runner pool while PRs wait (ADR-0034):

JobWhat it checks
macos checkcargo check --workspace --all-features on macos-latest
compose smokethe full Docker Compose topology, described below
msrv buildcargo +<rust-version> check --workspace --all-features, with the version read from the root Cargo.toml
coveragethe PR coverage job on master, same services, toolchain and cache key, so it gives a daily number and seeds the cache PRs restore

The msrv job runs cargo +<version> because rust-toolchain.toml pins stable and outranks the default a toolchain action sets, so a plain cargo check would test stable and never the declared version. It is red until #2026 settles rust-version against a lockfile that already needs 1.88.

None of these jobs is continue-on-error: nothing gates on extended.yml, and a failure has to reach the report failure job as failure. On master that job opens an issue titled extended.yml: nightly checks failing, labelled ci, the first time a run fails, and comments on it with the run link and each job’s result while it stays open. Close it once the fix lands; the next failure opens a new one. It is the only job in the workflow with write scopes, issues: write and actions: write at job level with no checkout.

An issue opened with the workflow’s own token raises no issues event, so project-automation never sees it. The job triages a new issue itself instead: it adds station:rtx and the Maintenance, CI & DX milestone, then dispatches project-automation.yml with the issue number, area=ci and effort=XS, which puts it on the board with Todo and Priority: Medium as well (#2201). Either half only warns when it fails, since a renamed milestone must not cost the issue itself; the warning names the command to run by hand. Breakage in these checks therefore shows up up to a day late, on that issue, rather than on the PR that caused it. To check a branch before merging, dispatch the workflow on it; a failure there shows in that run and leaves the issue alone:

gh workflow run extended.yml --ref <branch>

Full-stack compose smoke

The compose smoke job in extended.yml boots the production-shaped Docker Compose topology (Postgres, Redis, ClickHouse, gateway, control) and exercises it end-to-end. Run it locally with the same script CI uses:

bash docker/smoke/smoke.sh

It layers docker/docker-compose.ci.yml over the base compose file: the overlay mounts docker/smoke/rolter.smoke.toml (a keyless open gateway config) so the built-in fake-llm model answers without any provider secret. The script waits for both /healthz endpoints, checks /v1/models and fake-llm chat (non-streaming + SSE) on the gateway and the postgres-backed /internal/snapshot on the control plane, then always dumps compose logs and runs down -v. It runs nightly rather than on every push, because its cold Docker release build costs about five minutes of a runner (ROL-245, ADR-0034).

Published-port image smoke

The image-smoke job builds the single image from docker/Dockerfile and runs it the way the quickstart does: default command (rolter easy-up), ports published with -p, curled from the host. It checks three states: with no ROLTER_ADMIN_TOKEN and no ROLTER_ALLOW_OPEN_MODE the container exits with the refusal; acknowledged open, the gateway answers fake-llm and the control plane serves the dashboard; closed by a throwaway token, /internal/snapshot is 401 without it and 200 with it. A bind on the container’s loopback passes every check made from inside the container and answers nothing through a published port, which is how #1891 shipped. Run it locally against any tag:

docker build -f docker/Dockerfile --target runtime -t rolter:dev .
bash docker/smoke/image-smoke.sh rolter:dev

It needs no secrets and no compose stack, so unlike the compose smoke it runs on every push and is blocking. The release workflow’s smoke image job runs the same script against each architecture’s pushed digest before anything is published.

Contributing

Thanks for helping build rolter.

Workflow

  1. Branch from master: feat/<issue-number>-<short> or fix/<issue-number>-<short>.
  2. Make focused changes; add unit tests next to the code.
  3. Run cargo fmt --all, cargo clippy --workspace --all-targets -- -D warnings, and the tests (just test, or cargo nextest run --workspace + cargo test --doc --workspace).
  4. Use Conventional Commits for messages and the PR title.
  5. Link issues with Closes #N / Refs #N.
  6. Open a PR; fill in the template; CI must be green.

For parallel human or agent work, use the Worktrunk worktree workflow so each task has an isolated branch, index, and directory.

Code standards

  • Rust 2021; rustfmt defaults; clippy clean with -D warnings.
  • thiserror for library errors, anyhow in binaries.
  • Keep the data-plane hot path allocation-light; never block on locks (use arc-swap).
  • Code comments start lowercase, no trailing punctuation; /// doc comments use normal prose.
  • New balancing strategy → implement rolter_balancer::LoadBalancer + wire into build().
  • New storage backend → implement rolter_store traits behind a cargo feature.

Agent commits

Automated contributions include the trailer:

Co-Authored-By: Oz <oz-agent@warp.dev>

Don’t

  • Don’t commit secrets (use env / the encrypted store).
  • Don’t force-push master; force-push is fine on your own feature branches.
  • Don’t --amend after pushing shared history.

Parallel development with Worktrunk

Rolter uses Worktrunk as a thin lifecycle and visibility layer over standard Git worktrees. Each development agent gets an independent directory, index, and branch while normal Git history and GitHub remain authoritative.

The workflow is agent-neutral. Codex, Claude, Z.ai, Warp, and other agents all use the same worktree layout and branch rules. Tool-specific Worktrunk plugins are optional local integrations; they are not required by the repository.

Install

On macOS or Linux with Homebrew:

brew install worktrunk
wt config shell install

Alternatively, install the Rust binary:

cargo install worktrunk
wt config shell install

Restart the shell, then confirm that the shell wrapper is active:

type wt
wt --version
wt config show

The repository’s .config/wt.toml identifies GitHub as the forge and starts a background copy-on-write cache transfer for entries in .worktreeinclude. Only reproducible build caches are selected. Credentials and .env files must be configured independently in each worktree and are never copied by the repository hook.

Project hooks require one-time approval. Review the rendered command before approving it; agents should use --yes only after that review.

Start an independent task

Fetch first, then create the issue branch from the remote default branch:

git fetch origin master
wt switch --create fix/123-short-description --base origin/master

Use the repository branch format <type>/<issue-number>-<short-description>. Never add an agent or person name as a prefix.

Start the chosen agent inside the worktree that wt switch selected. An orchestrator without shell integration can obtain paths from structured output:

wt list --format=json

Automation that persists this output should explicitly select Worktrunk’s new schema until it becomes the default:

wt --config-set list.json-schema=2 list --format=json

Every agent must own exactly one branch and worktree. Never let two agents push the same feature branch. Worktrees isolate files and indexes, but branch refs and remote-tracking refs are shared by the repository.

Each worktree runs its Postgres tests against a database of its own, derived from the worktree path rather than configured, so two agents’ suites never share one (#1430). The server is shared: one rolter-test-pg container for the whole machine, which eval "$(just test-pg)" starts when it is not running and exports as ROLTER_TEST_DATABASE_URL either way. Never start a Postgres per worktree: it isolates nothing the per-worktree database does not, and it outlives the worktree (#1736). See testing.md.

Dependent tasks

Use an explicit parent branch as the base:

wt switch --create feat/124-dependent-change --base feat/123-foundation

The child pull request targets the parent branch, which makes the pair a stack: see Merge dependent work before merging either end of it. After the parent merges, fetch origin/master, rebase the child in its own worktree, validate it, and push with --force-with-lease. Do not retarget the child yourself — a stack refuses the base change — and do not run repository-wide branch synchronizers across active worktrees.

Inspect the agent fleet

wt list
wt list --full

Before assigning or cleaning work, inspect dirty state, divergence, conflicts, CI status, duplicate branches, and prunable registrations. Activity markers from Worktrunk plugins are advisory: a crashed or disconnected agent may leave a stale marker.

Commit and publish

Worktrunk manages worktree lifecycle only. Use normal repository commands for commits and publication:

prek run --all-files
git push --set-upstream origin HEAD
gh pr create --base master --draft

Use the immediate parent instead of master for a stacked pull request. Fill the PR template, use a Conventional Commit title, and link the issue with Closes #N only when the PR completes its acceptance criteria.

Do not use wt merge, wt step commit, wt step squash, or wt step push for Rolter delivery. Merge through GitHub only after hosted ci-ok, review, and acceptance-criteria verification. Worktrunk hooks are convenience automation, not a security boundary, and --no-hooks can bypass them.

Merge dependent work

Stacked pull requests are enabled on this repository. A chain of dependent branches merges bottom-up, and two of the habits that work for a standalone PR destroy a stack.

gh pr merge refuses a stacked PR outright:

This pull request is part of a stack and must be merged using the asynchronous merge REST API.

The endpoint that works is a PUT on merge-async. POST .../merge-async, POST .../async-merge and POST .../merges all return 404:

gh api -X PUT repos/rolter-ai/rolter/pulls/<n>/merge-async -f merge_method=squash

Never pass --delete-branch to a merge whose branch is the base of another open pull request. Deleting the base branch makes GitHub close the child, and that close cannot be undone: gh pr reopen and gh pr edit --base master both fail, leaving a new pull request with the same commits as the only way forward. Delete the branch after the whole stack has merged, or let wt remove do it.

Retargeting the child first is not a mitigation any more. GitHub answers Cannot change the base branch because the pull request is part of a stack. Merge the parent through merge-async instead: GitHub then retargets the child onto master by itself, but only on that path.

So the order for a stack is: merge the bottom PR with merge-async and no --delete-branch, let GitHub retarget its child, confirm hosted ci-ok on the child against its new base, and repeat upward.

Remove completed work

Confirm the PR is merged and the worktree is clean before removal:

wt list --full
wt remove <branch>

The shared pre-remove hook runs two commands inside that worktree before Worktrunk deletes it. cargo clean reclaims the copied target/ cache while the path still exists; source files and other worktrees are unaffected. scripts/test-postgres.sh release drops the worktree’s database on the rolter-test-pg server, matched by the worktree path the test harness records as the database’s comment. It never fails the removal: with the server down, or the database still in use, it does nothing.

Reclaiming the database does not depend on that hook. The next test run in any worktree drops every rolter_test_wt_* database whose directory is gone, which covers a worktree removed without Worktrunk, a branch that predates the hook, and a server other than rolter-test-pg. A worktree that is merely idle keeps its database. The container itself stays: it is one per machine, shared by every worktree, and just test-pg-down removes it. See testing.md.

Worktrunk deletes a branch only when it can prove the branch adds no changes to the default branch. When the merge state is uncertain, preserve the branch:

wt remove --no-delete-branch <branch>

Never use --force or --force-delete in an automated cleanup path. Treat prunable legacy registrations separately: inspect git worktree prune --dry-run, verify every path, and only then run git worktree prune.

Optional local integrations

Install only the plugins for agents used on a particular machine:

wt config plugins codex install
wt config plugins claude install
wt config plugins opencode install
wt config plugins gemini install

Agents without a Worktrunk plugin, including Z.ai or Warp-based agents, simply run inside the path created by wt switch. Repository behavior must never depend on a specific agent plugin being installed.

The dogfooding fleet

A local rolter with a fleet that looks like a real one, for sitting down as an operator rather than as the person who wrote the screen (#924).

The built-in fake-llm model answers “does the gateway work at all”. It does not answer “what is it like to run this”: one route with one target exercises none of the surfaces an operator lives in — provider groups, routing strategies, per-target health, key scoping, analytics — and every balancing strategy looks identical when every target answers in the same time.

The harness lives in integration/dogfood/. This page is the entry point; the directory’s README.md is the detail, including SigNoz and the .localhost names.

What it stands up

PieceWhere
fifteen fake OpenAI-compatible upstreams on 127.0.0.1:18001-18015integration/dogfood/fleet.ts
the gateway’s runtime config (listen address, request-log sink)integration/dogfood/gateway.toml
the fleet as desired state — fifteen providers, three provider groups, eleven routesintegration/dogfood/dogfood.toml
the keys those upstreams expect (fake, loopback-only, checked in on purpose)integration/dogfood/keys.env
the one local login every service sharesintegration/dogfood/creds.env
the SigNoz dashboards the session is read throughintegration/dogfood/signoz/dashboards/

Three shapes of upstream, named the way each of them names things:

  • :18001 — OpenAI’s model names (gpt-4o, o3-mini), added as an openai provider
  • :18002 — OpenRouter’s vendor/model names, declared kind = "openrouter"
  • :18003-18015 — a self-hosted vLLM/TEI fleet, one model per instance, roughly half behind a key

Three of them exist so failure is legible: vllm-a100-03 is ~4x slower than its pair, vllm-spot-01 returns a 503 for about a quarter of requests, and vllm-spot-02 takes ~1.4s to first token. A fleet with no bad targets leaves the health, breaker and latency screens permanently green, and a permanently green screen tells an operator nothing.

Running it

just dogfood        # datastores, fleet, control, gateway, SigNoz, then the sheet
just dogfood-sheet  # re-print every url, login, endpoint and model
just dogfood-key    # mint another gateway virtual key
just dogfood-seed   # re-import dogfood.toml over a running stack
just dogfood-adaptive off  # serve deepseek-r1 from its fallback stack (`on` undoes it)
just dogfood-ux     # prove the dashboard UX capture works, before relying on it

--import is desired state, so re-importing an edited dogfood.toml updates the rows it already created rather than duplicating them. The gateway never reads that file: it runs on gateway.toml, and gets the fleet from the control plane’s snapshot. The integration README’s which config file does what says which setting belongs in which file (#1288).

The whole stack binds to loopback, talks only to the fake providers, and holds nothing real. That is the only reason its credentials are checked in — do not carry them anywhere else, and leave docker/docker-compose.yml and the Helm chart on their own defaults.

Things that are load-bearing and easy to miss

  • The stack enforces RBAC, and that is the point. just dogfood generates ROLTER_ADMIN_TOKEN, ROLTER_INTERNAL_TOKEN and both peppers into integration/dogfood/.tokens.env on first run and serves /internal/* on its own port, 4002, as the e2e stack does (#636). RBAC only enforces once the control plane has an admin token, so a stack started without one runs with the operator API wide open — and then cannot surface the one class of bug dogfooding exists to catch: #942, where /me/* routes 401ed while every admin route passed, is invisible on a stack that never authenticates anything (#1649). A hand-rolled run must set all four. Curl the operator API with -H "authorization: Bearer $ROLTER_ADMIN_TOKEN"; just dogfood-sheet prints every value.

  • The two peppers must outlive a restart, like the KEK. ROLTER_KEY_PEPPER is mixed into a stored virtual-key digest and ROLTER_SESSION_PEPPER into a session, so regenerating either invalidates every key and logs everyone out. That is why they are written once to a gitignored file rather than being checked in beside the other local credentials, and why the gateway is given the same ROLTER_KEY_PEPPER as the control plane — the snapshot carries no pepper, so a mismatch rejects every key with 401 invalid api key.

  • The gateway names itself, and says so. ROLTER_NODE_ID still wins, but a gateway that has none now falls back to HOSTNAME and then to the gethostname syscall — which a shell-launched process does have, even though it has no HOSTNAME in its environment. It logs which one it used at boot (node identity resolved), and warns when nothing answered. Set ROLTER_NODE_ID anyway when running more than one gateway on one host, since otherwise both heartbeat as the same node. just dogfood sets it.

    This used to be silent: no id meant no node header, the control plane dropped both the cluster heartbeat and the adaptive-routing report with a 204, nothing was logged on either side, and the Cluster and Adaptive Routing screens stayed empty forever. The telemetry ingest now answers 400 to a report it cannot key on, which the gateway’s reporter logs as adaptive-routing telemetry report failed. See #1644.

  • Everything in dogfood.toml is in the explicit readonly tier. Providers and groups alike are written [[providers.readonly]] / [[provider_groups.readonly]] rather than as the deprecated bare arrays (ADR-0022), because this file is what a new operator reads before writing their own (#1657, #1650). The default tier is not an option for this stack: giving the control plane a config file switches it to MergedConfigStore, which drops db-created virtual keys from the snapshot (#623) and then 401s every key the stack mints. An unknown tier name is not an error — the config lint reports it as an unrecognised key and the entries are silently ignored, so a typo costs the whole fleet.

  • Provider groups only propagate through a seed or a restart, and do not fan out. A group created through the dashboard does not bump config_version, so the gateway never learns about it and group-slug/model 404s until it restarts (#1643). dogfood.toml seeds three groups for that reason, so group addressing is exercisable today — but every request to a group currently lands on its first member whatever the strategy and weights say (#1655). Groups in this file are in the explicit readonly tier: the default tier is seeded by the control plane from its own ROLTER_CONFIG, and giving the control plane a config file switches it to MergedConfigStore, which drops db-created virtual keys from the snapshot (#623) and 401s every key this stack mints.

  • allow_custom_api_base does not survive the database. dogfood.toml sets it on the OpenRouter-shaped edge, but the column does not exist yet (#1133), so once the fleet is seeded the control plane omits that provider from the snapshot and the claude-sonnet-4 route with it. The gateway says so on every reload and the dashboard shows it under config problems; it is expected until #1133 lands.

  • Adaptive routing is switched on by just dogfood. deepseek-r1 is the fleet’s strategy = "adaptive" route, and that strategy only routes once the deployment-wide kill switch is on. The switch ships off, and the importer does not write it (#1818), so just dogfood flips it on through PUT /api/v1/adaptive-routing-policy once the control plane answers (#1817). The route engages after min_samples picks, 50 by default. To compare against the fallback stack, run just dogfood-adaptive off or use Adaptive Routing → Settings. Either change survives a control-plane restart, and the next just dogfood turns the switch back on. Only enabled changes: blend weights set on the Settings screen stay as they are.

  • ClickHouse wants 262144 open files, and some runtimes cannot grant it. Rootless Docker or Podman and sandboxed runners often cap nofile lower. There both ClickHouse containers fail with error setting rlimit type 7: operation not permitted and everything downstream (request logs, ui_events, SigNoz) is missing. Put CLICKHOUSE_NOFILE=20000 in docker/.env there, not in a repository-root .env, which compose does not read for these files (#1819). A one-off CLICKHOUSE_NOFILE=20000 just dogfood works for that run, but the next compose call without it, just signoz-reset included, recreates ClickHouse at 262144.

  • Analytics need the gateway’s [logging].clickhouse_url, not just the control plane’s CLICKHOUSE_URL. The control plane’s only lets it read the table (#929).

  • The SigNoz dashboards are tied to the SigNoz pin. provision-signoz.sh targets v0.136.0, the release docker/docker-compose.signoz.yml pins, which moved sign-in to /api/v2/sessions/email_password and dashboard creation to /api/v2/dashboards (#1864). The boards are written in the v1 import format with "version": "v5", which v0.136 converts on import. A SigNoz the script cannot talk to makes it exit 2 naming the version, a sign-in route that moved included: only a refused credential is reported as a different account and pointed at just signoz-reset. just dogfood repeats the failure after the sheet, and just dev-creds exits with it. Run just signoz-provision against a new release before moving the pin.

Before a week of capture: prove the UX stream

A dogfooding week is worth what it captures. The dashboard’s own UX stream — screen views, time-to-interactive, form abandons, error states — is the half that cannot be reconstructed afterwards from traces, and it is also the half with no failure signal: ui/src/lib/ux.ts swallows every error on purpose, so a stack that is storing nothing looks exactly like a stack nobody clicked. A week run on a broken pipeline is a week that has to be run again.

So start it by proving the pipeline instead of assuming it:

just dogfood-ux

That signs in, posts a probe event through the real POST /api/v1/ui-events, reads it back out of ClickHouse, and fails loudly with the reason if any hop is broken. It first applies clickhouse/*.sql to the running server, which is not a formality: docker-compose mounts that directory into ClickHouse’s docker-entrypoint-initdb.d, and those scripts run only when the data directory is first created. A chdata volume older than a given migration has never seen it, so a machine that has been running this stack for a while may have no ui_events table at all — and then every batch is a 500 and the week captures nothing. just dogfood now applies them on every boot for the same reason.

What a mid-week ClickHouse hiccup costs

Every row here was produced by breaking the running stack and watching it, and the full table with the server’s status codes is in UX telemetry.

  • ClickHouse restarts under you: the events queued during the outage are gone, and the stream resumes by itself afterwards. Bounded loss, no action needed — the browser gets a 500, drops that batch, and keeps flushing.
  • The ui_events table is missing: the same 500, but it never clears, so the whole week is lost. This is the one just dogfood-ux exists to catch.
  • Your dashboard session lapses while a tab is open: the stream disables itself for the life of that tab. The screens keep working, so nothing hints at it — reload the tab after re-authenticating, and prefer to close tabs you are no longer using rather than leave one open overnight.
  • A batch is rejected: everything that shared that flush is dropped, not just the offending event. A flush is at most five seconds of interaction.

In every case except the last the loss is silent from both ends: the dashboard shows nothing and the control plane logs nothing. Re-running just dogfood-ux at the end of a session is the cheapest way to know the capture was still alive when the session ended.

Using it

The point is not that it boots. The point is to do operator work against it — add a provider, group it, route to it, scope a key, push traffic, then read the dashboard and the traces back — and to file an issue for every place that goes badly. A finding mentioned in a summary is lost; a finding on the board is not. The board conventions are in issue tracking.

If an operator action produces no useful span in SigNoz, that is an observability gap and worth its own issue too.

Walking it as someone else

“Do operator work” has a script now: the user journeys walk each kind of person — org admin, team lead, engineer, service, FinOps, SecOps, DevOps, viewer — through their goal, step by step, with the status of every step and the issue behind every gap. They sign in as persona accounts, which one command creates on this stack:

just dogfood-personas        # or ./integration/dogfood/personas.sh

It is idempotent and prints the roster: one account per persona, each holding exactly the role and scope its script assumes, all sharing the dogfood password from creds.env.

The scripts also run unattended. integration/dogfood/journeys/ holds one TypeScript file per persona that walks its steps against this stack. Dashboard steps run in headless Chromium, signed in through the real login form. The rest call the API as the persona or with the keys the script mints, and each script cleans up what it made:

just dogfood-journeys              # every script; `just dogfood-journeys lead app` for some
just dogfood-screens               # every persona account against every dashboard screen

Results, one screenshot per step and a summary table land in integration/dogfood/.journeys/. The runner uses the dashboard’s own playwright-core (cd ui && bun install first), and CHROMIUM_PATH points it at a system Chromium when Playwright’s is not installed. The CLI steps (kek verify, mfa reset, config export) need cargo build -p rolter --features postgres and are skipped without it.

Dashboard localization (i18n)

The dashboard SPA under ui/ is translated with i18next + react-i18next. English is the base catalog; Russian ships alongside it. Switching language is instant — react-i18next re-renders the tree, nothing reloads and no route changes.

Everything is bundled. There is no CDN, no language-detection service and no runtime fetch to a translation host, because rolter must run fully air-gapped.

Layout

PathWhat it is
ui/src/lib/i18n/index.tsi18next init, locale detection, setLocale()
ui/src/lib/i18n/locales/en.jsonbase catalog — the source of truth for keys
ui/src/lib/i18n/locales/ru.jsonRussian catalog
ui/src/lib/i18n/format.tsuseFormat() — Intl number/currency/date bound to the active locale
ui/src/lib/i18n/parity.tsthe parity rules, shared by the test and the CI gate
ui/src/lib/i18n/literals.tsthe hardcoded-literal rules, likewise shared
ui/src/lib/i18n/literals-allowlist.tsthe notation the literal gate tolerates, each entry with its reason
ui/src/components/LocalePicker.tsxthe switcher in the sidebar footer
ui/scripts/check-i18n.tsbun run check:i18n — the catalog-parity CI gate
ui/scripts/check-literals.tsbun run check:literals — the hardcoded-literal CI gate

en is imported statically because it is the fallback and must always resolve. Every other locale is a dynamic import(), so Vite emits it as its own chunk and a user only downloads the language they actually pick.

Key naming

Keys are nested and grouped by where the copy lives:

PrefixHolds
common.*words reused everywhere — Cancel, Delete, Search…
shell.*the app frame: sidebar chrome, user menu, screen header
nav.<screen-key>one sidebar label per nav entry
screens.<screen-key>.title / .subtitlethe screen header, also used by the stub
pages.<screen-key>.*copy inside a screen
auth.*, scope.*, locale.*the login screen, scope switcher, language picker

<screen-key> is the nav key, which is also the route path — so a screen’s label, header and body copy are all found by the same string.

Keep keys semantic (pages.dashboard.noTraffic), never the English text itself: re-wording the English copy should not force a key rename.

Writing translatable copy

import { useTranslation } from "react-i18next";

const { t } = useTranslation();
return <h2>{t("pages.dashboard.recentTitle")}</h2>;

Interpolation — name the value, never concatenate:

// en.json
"roleWithOrg": "Admin · {{org}}"
t("shell.roleWithOrg", { org: orgName })

Plurals — use count, and let i18next pick the form. English needs two forms, Russian needs four; the catalogs differ on purpose and the gate knows the difference:

// en.json                    // ru.json
"errors_one":   "{{count}} error",     "errors_one":   "{{count}} ошибка",
"errors_other": "{{count}} errors"     "errors_few":   "{{count}} ошибки",
                                       "errors_many":  "{{count}} ошибок",
                                       "errors_other": "{{count}} ошибки"
t("pages.dashboard.errors", { count: errors })

Markup inside a sentence — use <Trans> with numbered slots, so each language can put the emphasised part where its grammar wants it:

"deleteHint": "This removes <0>{{name}}</0>. This cannot be undone."
<Trans
  i18nKey="scope.deleteHint"
  values={{ name }}
  components={[<span key="name" className="font-mono" />]}
/>

Never interpolate a noun into a sentence frame. "Add {{level}}" cannot be declined correctly in Russian (and most inflected languages). Spell each case out as its own key — scope.addOrg, scope.addTeam, scope.addProject — and select it with a lookup table in the component.

Numbers, money and dates

Use useFormat(), never bare toLocaleString(). A bare call follows the browser locale, so a ru-RU browser rendered Russian separators inside an otherwise English panel.

const fmt = useFormat();
fmt.number(1234567);     // 1,234,567   / 1 234 567
fmt.currency(0.0004);    // $0.0004     (sub-cent costs stay visible)
fmt.percent(0.734);      // 73.4%
fmt.dateTime(row.ts);

A figure whose unit depends on the row goes through one table rather than a format call per site. An alert rule’s threshold and last value are the model: ui/src/lib/alert-signals.ts maps each signal to its unit, the scale between what the API stores and what the form takes (error_rate is a 0-1 fraction typed as a percentage), its default threshold and its formatter, so the card, the form and the request body cannot disagree (#2125). Intl joins some units to their number with a plain space (ru 840 мс), so a figure on a narrow card binds the two with a no-break space.

Adding a locale

  1. Copy ui/src/lib/i18n/locales/en.json to <code>.json and translate it. Leave locale.en / locale.ru / … as native names in every catalog — a language is always listed in its own language, so a user who picked the wrong one can find their way back.
  2. Register it in ui/src/lib/i18n/index.ts: add the code to LOCALES, a native name to LOCALE_NAMES, a two-letter badge to LOCALE_SHORT, and a dynamic import to loaders.
  3. Run bun run check:i18n — it will list every key you still owe, and every plural form the language requires.

No other file changes. The picker builds itself from LOCALES.

Locale selection

detectLocale() resolves, in order:

  1. the stored choice in localStorage["rolter.locale"],
  2. the browser’s navigator.languages, matched on the primary subtag so ru-RU resolves to ru,
  3. en.

Picking a language persists the choice and sets <html lang>. This is dashboard-only state — it is not written to the account, so it needs no control-plane change. (Per-user server-side persistence is tracked separately.)

Keeping catalogs honest

cd ui
bun run check:i18n     # the CI gate
bun test src/lib/i18n  # the same rules, plus runtime behaviour

The gate fails on: a key missing from a locale (which would silently fall back to English mid-sentence), an orphaned key whose screen is gone, an empty string, a dropped {{placeholder}} or <0> slot, and an incomplete set of plural forms. Both entry points share ui/src/lib/i18n/parity.ts, so bun test and CI cannot disagree.

When you add or re-word copy, update en.json and every other catalog in the same PR — a half-translated catalog is a CI failure, not a follow-up.

Copy that arrives as data

check:literals only sees strings written into components, so prose sent by the control plane slips past it and renders in English whatever the locale. Where a backend value is shown to the user, send a stable identifier and look the copy up by it rather than rendering the server’s text. The experimental note is the worked example: GET /api/v1/stability carries an English note for API consumers, and the rail renders stability.notes.<id> instead (#1401). ui/scripts/stability-notes.test.ts keeps those keys in step with SUBSYSTEMS in crates/rolter-core/src/stability.rs; see Stability markers.

Catching strings that never reach a catalog

Catalog parity is a property of the catalogs. It cannot see the failure that actually happens: a string hardcoded in JSX, which is not in any catalog by definition. That is how EditorSheet shipped window.confirm("Discard unsaved changes?") and a default "Cancel" label with a green merge gate (#871).

cd ui
bun run check:literals            # fail on any literal that is not in a catalog

It scans src/components/**, src/pages/**, src/lib/** and src/App.tsx for four shapes:

KindWhat it matches
dialoga string literal passed to window.confirm / alert / prompt
propprose assigned to a user-facing prop, destructured default or object key — the names are listed below
textprose sitting directly between JSX tags, interpolation included
errorthe message of a thrown Error / ApiError, which LoadError prints, or one handed to a message setter such as setError(…) / setFormError(…)

The user-facing names are a closed list, USER_FACING_PROPS in literals.ts, so a string under className, type or id is never read:

NamesWhere the dashboard renders them
title, subtitle, description, label, placeholder, emptyText, aria-labelheaders, fields, empty states, accessible names
confirmLabel, cancelLabel, saveLabeldialog and sheet buttons
descRelatedLink, FeatureFlags and the settings screens’ rows
hint, infoField and SwitchRow — the line under the control and the InfoHint beside it
text, tooltipInfoHint, and a hover explanation
detail, message, bodya toast’s second line, a notice’s message or body
noteScopeNote and the cost attribution notes
error, errorMessageField‘s validation line, the create dialogs’ failure line
altan image’s accessible name
header, groupa table column’s heading, the header a ComboboxOption sits under
eyebrowPageLead and SettingsPanel’s kicker
xLabel, yLabel, unit, xUnit, yUnit, centerLabel, centerSubthe charts’ axis titles and units, the donut’s centre
removeLabel, ariaLabel, empty, experimentalNote, unavailableReasona chip’s remove button, a Segmented group’s name, a screen’s empty slot, a flag
aria-description, aria-roledescription, aria-valuetext, aria-placeholderthe rest of what a screen reader speaks
heading, caption, legendthe html elements’ own captions

The list is closed, not frozen: when a component takes copy under a name that is not in it, add the name in the same PR, with a case in literals.test.ts (#1545).

Where a single lowercase word is copy

Everywhere else a bare lowercase word — chat, inherit, idle — is far more often a code than a label, so it is not reported. In a position that renders the string as it stands, the dashboard’s lowercase style makes it copy, and the gate reads it (#1745):

  • the literal value of a copy prop: aria-label="close", placeholder="unchanged"
  • a JSX text node: <span>disabled</span>, including one that opens with a glyph, + Add rule or ← Back (#1872). The glyph beside a translated label, + {t("pages.alerting.rules.add")}, is notation and stays quiet
  • a value a rendered expression can evaluate to — a ternary branch, a ?? / || / && fallback: {open ? "hide" : "show"}, {row.target || "unknown"}. An operand is not a value: kind === "chat", fmt(n, "compact") and { dateStyle: "medium" } stay quiet
  • a template with one word beside a value: `${n} tokens`
  • a message setter’s argument, and a local the screen renders by name

An object key is not such a position. { value: "json", label: "json" } is an option echoing its own wire value as often as it is a label, and the key is read with the ordinary rules.

Three more shapes are read wherever copy goes:

  • concatenation. A fragment glued on with + whose edge is a space — name + " settings", "Owned by " + owner — is a piece of a sentence and is reported, capitalised or not. Build the sentence as one catalog entry with its interpolation instead; word order is the translator’s call
  • an indexed table. {HINTS[mode]} or title={COPY.save} reads every value of const HINTS = { … } in the same file, word by word
  • prose beside any expression. Children are walked element by element, so a sentence next to {lagging > 0 && …} — whose > used to hide the whole run — or a label after a conditional spinner is read. The walk follows JSX nesting, so code after the outermost element closes is never taken for text

Copy that crosses a line of code

The scan reads the whole tree at once, not file by file (#1765). Four more shapes are followed to where the string is written:

  • interpolations. The values of a t("k", { … }) options object and of a <Trans values={{ … }}> are spliced into translated copy, so a literal there is copy: t("toast.deleted", { what: "rule" }). i18next’s own switches — count, context, ns, lng and the rest of T_OPTION_KEYS — are skipped
  • helpers. A call that is the whole of a rendered expression — {stateLabel(row)}, hint={hint(on)} — reads the helper’s return values (a declared function or an arrow bound to a const). A return that opens with a tag is a component’s render, which the JSX rules already read. The helper is followed one call deep: a helper that returns another helper’s result is not read through
  • shown state. const [log, setLog] = useState(…) whose value is rendered bare ({notice}) or mapped ({log.map(…)}) makes setLog a place copy goes, and so is any local function that calls it: append("● connected"). An updater function (setLog((l) => […])) is code, and state that is never rendered, such as a tab id, is not read
  • imports. A table, constant or helper imported from another scanned file and rendered in this one ({STATUS[s]}, {EMPTY}, label={roleLabel(r)}) is read in the file that exports it. @/ and relative specifiers are resolved against the scanned set, as renames included, and the finding names the exporting file, since that is where the fix or the allow-list entry goes

A status glyph in front of a word does not make it notation — ● connected reads like connected — but path and flag punctuation does: /model, --verbose.

Still out of reach, and left to review:

  • a useState seed or other sample input: seed it from the catalog, useState(() => t("…")), as the playground does
  • a namespace import (import * as copy), a default export, and a re-export chain; none of them carries copy in the dashboard today
  • anything the src/lib/i18n/ modules export, which the scan skips as the machinery itself

The key of a t(…) call is blanked before matching, so a translated string costs nothing and hides nothing beside it; its options are read as above. Acronyms, identifiers, URLs and bare numbers are not copy and are ignored, and so are class lists. Case does not make a class list: lowercase copy such as request failed: {…} or no events yet is reported like its capitalised spelling (#1546). A phrase is skipped as a class list only when every token is a utility, meaning it carries a dash, a bracket, a slash, a percent, a digit or a variant colon (px-3.5, w-[9px], sm:block, grid-cols-[1fr_2fr], bottom-[calc(100%+6px)]), or it is one of the bare utilities and css keywords (flex, relative, truncate, none, auto, srgb, …), with a css function’s parentheses and commas ignored. One plain word makes it prose. Stories, tests and the two story fixtures (story-harness.tsx, shell-harness.tsx) are not scanned: a story’s job is to render a component with concrete sample text, and routing that through the catalogs would make the stories test the catalogs instead of the component.

No baseline: copy goes in the catalogs, notation in the allow-list

The gate shipped with literals-baseline.json, a recorded list of several hundred literals that predated it and that it tolerated. It could only shrink, but it read as clean on every run while most of the ru dashboard was English (#958). That debt has been paid off and the baseline is gone: a screen is not done until every string an operator reads is in the catalogs, translated in every locale.

What the scan still reports and nobody would translate — a request parameter shown as n=1, a version as v{…}, a {…} rpm unit, a developer invariant no operator can reach — is listed in ui/src/lib/i18n/literals-allowlist.ts. Each entry maps a file and the exact reported text to the reason it is not copy:

"src/pages/Playground.tsx": {
  "n=1": "the OpenAI images `n` request parameter, shown as the value sent",
},

There is no command that records findings into it. An entry is written by hand and reviewed, the gate fails on an entry with an empty reason, and it fails on an entry whose text is no longer in the source, so a string that was translated cannot come back unnoticed. When the scanner misreads code as prose, as it did with arbitrary Tailwind values, fix the scanner with a case in literals.test.ts rather than allow-listing the string.

What the scanner sees

It reads the whole file, not one line at a time (#1200), so it finds: every literal on a dense one-line component, prose that wraps across lines inside a tag, the strings inside an expression in text or prop position ({pending ? "Saving…" : "Save"}, title={edit ? "Edit" : "Add"}), prose that shares a text node with an interpolation (#1355) along with the strings inside the expressions beside it (#1371), a window.confirm whose string sits on the next line, and the message of a thrown Error/ApiError — which LoadError prints — whether it is a string or a template literal (#1390).

Every {…} in children position is read, not only an element’s single one (#1594): a button that renders a spinner beside its label writes two in a row, {pending && <Loader2 />}{draining ? "Return to service" : "Drain"}, and the second used to sit behind a } where the pattern only looked after a >. The tags nested inside such an expression are blanked before its strings are read — a className on a nested <span> is a prop, not children.

It also follows copy that reaches the screen without sitting in JSX (#1537):

  • a user-facing key in an object literal, { label: `Other (${n})` } or { title: "Rate limits" } — the shape of every tab list, range picker and chart slice;
  • a dropdown’s whole vocabulary. The migration off <select> (#968) moved option copy out of <option> text and into label:, description: and group: on ComboboxOption, so those keys are where it is read now (#1594);
  • a table column’s header:, which is data and was never a JSX text node;
  • a local binding whose name is rendered bare where copy goes, const cta = add ? "Create" : "Save" followed by <Button>{cta}</Button> or title={cta}. A binding that only ever reaches code is not read, and neither is a function.

A template literal records like a mixed text node, each ${…} collapsed to {…}, and it counts only when its prose does: ${a}/${b} and /v1/${id} carry no sentence and are left alone. It scans src/components, src/pages, src/lib and App.tsx; the i18n machinery, stories and tests are skipped. Comments, regex literals and the key of each t() call are blanked before matching; the call’s options stay, to be read as interpolations.

A sentence that carries a value

<p>Charged to {team.name} monthly</p> is one finding, Charged to {…} monthly — the runs of one element are joined and every interpolation collapses to {…}. That is the shape a catalog entry has to hold, and it is the copy that most needs one, because interpolation order differs by language. Reporting each run on its own would record Charged to and monthly, neither of which anybody would translate.

A region with no prose in it is not copy and is not reported, so {a} · {b}, {count} ({total}) and a lone {status} are never reported. {" "} is whitespace rather than a value, so it collapses to a space, not a placeholder. The usual thresholds still apply on the joined string: three characters, at least one lowercase letter, and not a class list, a URL or a bare number.

Which > ends a tag

A text node is whatever sits between a > and the next <, so the scan is only as good as its answer to “is this > the end of a tag”. A generic (React.useState<Row[]>({…})) and a comparison (a > b && c < d) both used to read as one, and the code behind them was recorded as copy — extends React.HTMLAttributes and b && c were recorded as copy (#1370).

The question is answerable from the < end, so the scan starts there: a tag opens as <Name, </Name or <> and never flush against an identifier, which is what a generic does (Promise<, Record<). From that < the tag runs to its own >, skipping attribute strings and everything inside {…} — the => in onClick={() => close()} is an arrow, not the end of the button’s tag.

What it still cannot see

A binding is only followed when its own name is rendered bare (#1537), so copy held in one that reaches the screen some other way stays invisible: through a member expression ({bag.cta}), through a call ({upper(cta)}, {tabs.map(…)} over an array of labels), destructured out of something else (const { cta } = pickCopy()), or never rendered at all. Only const and let are read, and a binding holding a function or an arrow is skipped on purpose — its body is the rest of a component, not a held value.

A sentence assembled by concatenation is not reassembled. Each quoted piece is matched on its own, so {"Charged to " + team + " monthly"} is reported as Charged to and nothing else, and a piece that is a single lowercase word ({who + " monthly"}) reads as code and is dropped entirely. Write it as one catalog entry with a placeholder instead — which is what the language needs anyway, since interpolation order differs.

A string under a prop or object key outside USER_FACING_PROPS is never read, by design: caption="Save now" is as invisible as className is. Add the name to that list, with a case in literals.test.ts, rather than working around it.

A copy map used to be invisible for the same reason — its keys are codes, not prop names — and since #1599 it is not: an object whose every member is a code: "string" pair, with more than one member, has its values read as copy. const MODES = { off: "Off", on: "Streaming on" } fed to a combobox through Object.entries now reports both labels. Build the options from t() instead of mapping a code to English.

What that rule deliberately stands down for, since a lookup table full of wire values is the normal case: a value that is not a phrase and was not capitalised (cache-first, provider_name, /api/v1/keys, bg-green-500, scope.org, POST); an object where any member is not a plain string, which makes it a config object rather than a table; and one whose keys name a prop rather than a wire value (id, className, kind, and the rest of NOT_A_TABLE_KEY) — there the copy-carrying names in USER_FACING_PROPS are the precise rule and this one would only add noise. A language’s own name (Bash, TypeScript) is a real finding under this rule and belongs in the allow-list, which is where the ones in code-block.tsx now sit.

Why the set does not depend on formatting

The findings have to be a property of the code, not of where its lines break. They were not, and it broke the ratchet twice (#1143): re-wrapping dense JSX moved strings in and out of the detected set with nothing added or removed from the source, and accept="image/*" opened a block comment for the comment-stripping regex, blanking every literal between it and the next */ a hundred lines further down — seven strings in Playground.tsx, including aria-label="Attach image", were invisible to the gate that way.

So maskSource in literals.ts runs first. It is a small TS/JSX tokenizer that tells strings, template literals, comments and regex literals apart, blanks what is never shipped, and collapses every whitespace run in code position to a single space. Two spellings of the same component — one line or twenty — mask to the same string, so they yield the same literal set; literals.test.ts asserts exactly that on a fixture written both ways. The mask carries an offset map back into the original file, so a finding still reports the line you have to edit.

It is still a lexical scan, not a TypeScript parse. The question is “does a user-visible English string appear where a t() call belongs”, and every candidate is a string literal or a JSX text node — both of which the masked source plus a regex reads accurately enough to answer it. A real AST pass is not on offer anyway: the typescript package the dashboard depends on is the Go port, which ships no in-process createSourceFile to walk.

Two heuristics in the tokenizer are worth knowing about, because both trade a theoretical miss for a practical one. A / after < or } is never read as a regex literal, since in .tsx those are </Foo> and {x}</Foo> and swallowing a closing tag would cost the rest of the component. And a quote with no closing quote before the end of the line is ordinary text, not a string — otherwise the apostrophe in JSX prose would eat the copy after it.

A t("…") call’s key is blanked out of a line before it is matched, rather than the line being skipped whole. The difference matters on the dense screens: they are written one long statement per component, so “the rest of this line” is routinely the rest of the component, and a single translated header used to hide every literal beside it (#1092). Blanking keeps the column count, so a reported position still points where the string is.

Dashboard error states

A screen that cannot load its data has to say why. Before #962 every screen rendered the same sentence — Failed to load X. — for causes needing entirely different responses, so it pointed at none of them.

That is not a hypothetical cost. During the #924 dogfooding pass the Keys screen showed “Failed to load your keys.” while the real cause was every /api/v1/me/* route returning 401 (#942). The message sent the operator to check their key configuration; the actual cause was found afterwards by reading traces. An error that cannot separate you are not signed in from the server is down costs more time than no error at all, because it invites a wrong hypothesis and the operator spends their attention there first.

The rule

Never render a load failure by hand. Use LoadError:

{keys.error && (
  <LoadError
    error={keys.error}
    resource={t("errors.resources.virtualKeys")}
    onRetry={() => keys.refetch()}
  />
)}

resource is the translated noun for what failed — it is interpolated into both the title and the body, so each reads as a sentence in every locale. Some bodies name it, so a call site that filled only the title showed the reader a raw {{resource}} (#1362); catalog parity cannot catch that, because the placeholder is present in every locale and it is the render that drops it. src/lib/load-error.test.ts holds the copy to the one variable the component passes, and the EveryKind story asserts no {{ survives to the DOM. Pass onRetry whenever the caller holds a query handle; the component decides whether offering it is honest.

What it distinguishes

classifyLoadError in ui/src/lib/load-error.ts maps a thrown value to one of seven kinds. ApiError already carries status and the control plane’s code, so no screen has to parse a message to find out what happened.

kindcauserecovery offered
unauthenticated401sign in again
forbidden403none — ask an administrator
openMode401 with code open_mode_no_sessionnone — set ROLTER_ADMIN_TOKEN
noStore404 with code no_such_endpoint (or that message prefix from an older control plane)none — set ROLTER_DATABASE_URL
unreachablethe thrown value is not an ApiError, so fetch never connectedretry
server5xxretry
unknownany other non-ok statusretry

noStore is the third one that looks like something else. Every CRUD and settings route is mounted only when the control plane runs with a database, so a config-file-only deployment answers Users, Keys, Providers and every settings screen with the API’s JSON 404. That is the deployment’s shape, not a wrong URL and not a failure a retry can change (#1204).

No analytics store is not a load error

A control plane with no ClickHouse is a deployment shape rolter supports, so LoadError has no kind for it. The analytics routes are mounted; they just have no ClickHouse behind them, so they answer 503, and a control plane too old to serve a route answers 404. getAnalytics turns both into an AnalyticsUnavailableError rather than an ApiError, which carries no status. Read as a load failure it would be wrong twice over: the status-less rule calls it unreachable and tells the operator the control plane could not be reached while it was answering every request (#1236), and a red role="alert" puts a deployment shape in the voice of a 500 that a screen reader announces as urgent on every visit (#1984, #1976, #2016).

Every screen that reads analytics states the answer in the shared AnalyticsUnavailable panel in ui/src/components/, an informational role="status": a neutral surface, the CLICKHOUSE_URL guidance in monospace, the control plane’s own message under it, and no retry, because no retry can help. A screen passes its own copy as i18nKey, with a title and a body under it, and the body says what the screen will show once the store is there. The panel is chosen on the error’s class, before LoadError is reached:

{error instanceof AnalyticsUnavailableError ? (
  <AnalyticsUnavailable error={error} i18nKey="pages.logs.noAnalytics" />
) : (
  <LoadError error={error} resource={t("errors.resources.requestLogs")} onRetry={retry} />
)}
screenwhere the panel goes
LLM Logs (#1984)in place of the table and its filters
Dashboard (#1976)in place of every card, once any read answers it while holding nothing; the setup checklist above it stays
MCP Logs (#2016)in place of the stats and the table; also inside a call’s detail drawer if the store goes away under an open list
Cost Attribution (#2016)in place of the spend strip; the business units or customers below it keep working, since they are read from Postgres
Account (#2016)above the key cards; each card still says its usage is unavailable, and the keys can still be created and rotated

A screen that hands an AnalyticsUnavailableError to LoadError anyway gets the server kind, not unreachable: the control plane did answer. That is a fallback for a screen that forgot the panel, not a way to show the state.

On the Dashboard the panel is one answer for the screen rather than one per card. It replaces every card as soon as any of the four reads answers AnalyticsUnavailableError while holding no data, since each card would otherwise be a skeleton or an error about a store that was never there. The setup checklist above it stays, because it reads rows rather than traffic.

A polled query that fails

A screen that polls with refetchInterval can fail in a way a one-shot read cannot. react-query sends a query that has never held data back to pending on every refetch, and clears its error when it does. So a poll that keeps firing after a failed first load unmounts the LoadError, shows the skeleton while the retries run, then mounts the alert again. A screen reader hears the alert anew on every cycle. LLM Logs did this every five seconds (#1984).

Stop polling while the query is in error with no data, using a function refetchInterval:

refetchInterval: (q) =>
  streaming && !(q.state.status === "error" && q.state.data === undefined) ? pollMs : false,

The LoadError’s retry is then what asks again, and a retry that lands starts the poll again. A failure with rows already on screen keeps its error through a refetch, so that case may go on polling, and should say it is retrying.

Anything that claims the feed is live (a pulse, “Streaming”) reads the query, never the pause toggle alone. It says live only after a fetch that succeeded. On a failure it says so, gives the time from errorUpdatedAt through useFormat(), and says “retrying” only when a poll will actually retry. The Failed story in Logs.stories.tsx holds a reference to the alert node across three polling intervals and asserts it is still connected: an alert that flickered would have been unmounted and replaced, so the held node would be detached even if a new alert had since appeared.

The Dashboard polls four queries under the same rule (#1975), and each card owns the state of the read behind it (#1976). The four figures, the spend chart, the traffic donut, the by-model bars and the recent rows each read one query through CardRead, which shows the card’s own skeleton while the read is awaited, the card’s own LoadError with a retry for that read alone when it failed holding nothing, and its content once it holds data. The content is a render prop handed the data, so a card cannot say “No traffic yet.” or “Nothing logged yet.” about a read that has not answered or that failed. One failing endpoint takes down the cards that read it and leaves the rest of the screen up. The donut and the bars read the same endpoint, so one failure of it is one alert: the traffic share card holds the LoadError and its retry, and the by-model card holds a plain sentence (pages.dashboard.sharedRead, not a live region, no button) saying it reads the same data and where to retry. Either card comes back with that read.

When every read fails at once the screen says so once (#2342). All four queries in error holding nothing, and none of them the no-analytics answer, replace the cards with one screen-level LoadError that names the analytics (errors.resources.analytics), quotes the error of the figures read, and offers one Try again that refetches all four reads. The setup checklist stays above it, as it does above the no-analytics panel. Before, a total outage put five role="alert" blocks on screen, so a screen reader announced five alerts on mount, two of them (the traffic share and the by-model bars) stating one failure twice.

A partial failure is unchanged: a card whose read failed holds its own alert while the ones that answered stay up, and those keep polling. So does a failure next to a read that is still out, since a read that has not answered is not a failure: the screen becomes one alert only when the last read fails. The per-card error signals of useErrorState are withheld while the screen-level alert is the placeholder on screen, so one outage is one signal, dashboard-analytics.

A poll that fails over data a card already shows keeps that data, and every card says so. RefreshFailed writes Refresh failed at {time}, retrying in the danger text colour under the figures and under each chart, and the Recent requests card, the one that says “Live”, swaps the word for the same sentence in its label, or for Load failed at {time} when its first read failed and polling stopped. The line is plain text rather than a live region: every failed poll rewrites it, and five cards announcing once a minute would be noise. Each card keeps the pollEvery rule for its own read, so a query that never held data stops polling and holds its alert.

The Recent requests label follows the same rule as every feed that claims to be live: it says Live only once a read has succeeded. While the first read is out it says Loading, and on a failure it gives the time. It used to say Live from the first paint because it tested !isError, which is also true of a read nobody has answered yet (#2341).

The AFailedPollKeepsWhatLoaded and AFirstLoadThatFailsStopsPolling stories in Dashboard.stories.tsx pin the two rules; the second one is the total outage, and asserts exactly one alert, held across three intervals with nothing asked, whose retry asks for each of the four reads once. OneFailedCardLeavesTheRestUp, AFailedRecentReadIsNotAnEmptyOne, TheTwoCardsOnOneReadShareOneAlert, SeveralFailedCardsEachHoldTheirOwnAlert, AReadStillOutIsNotYetAnOutage and AFailedCardHoldsItsAlertWhileTheOthersKeepPolling pin the partial failure: the per-card alert, its retry, where the outage starts, and the polling that goes on around it. The …IsStillLoading stories pin each card’s skeleton, and TheRecentLabelSaysLiveOnlyAfterTheFirstRead the label.

A polled screen that reads a time window keeps the window’s name in the query key and works the bounds out inside the query function, with windowBounds() from ui/src/lib/time-window.ts. Bounds fixed when the screen mounted keep widening on a tab left open, so “the last 24 hours” would mean every hour since the tab was opened, and a key that carried a fresh timestamp would fetch on every render. The Dashboard and LLM Logs (#2315) do this, and the TheWindowRollsForward… stories record two reads and assert the second since is later than the first.

Two of these are easy to collapse and must not be. A plain 401 is fixed by signing in; open_mode_no_session is a control plane running with no admin token, which has no accounts to sign into at all — signing in again is exactly the wrong advice. And a retry button on a 403 suggests the failure was transient when it was a permission, so isRetryable withholds it.

A 401 is handled once, not per screen

Since #1196 the shell owns the expired session. api.ts calls the handler AuthProvider registered through setSessionExpiredHandler whenever a request that carried the session token is answered 401 — the token and the cached account are dropped and the sign-in screen explains why. A screen still renders LoadError for the request that failed, but it no longer does so with a dead token attached and no way out except signing out by hand.

Three 401s are deliberately not that: open_mode_no_session (there is no account to sign in to), a refused POST /api/v1/auth/login (the password was wrong, the session was not), and an expired invitation token. None of them says anything about the session in localStorage.

Two things that are not this component

An empty result is not a failure. A successful request returning zero rows renders an empty state. Routing it here would tell an operator something is broken when nothing is. The placeholder for that, and the one for a request still in flight, are in loading and empty states.

A failure is not an empty result either. The reverse mistake is the common one. A failed read holds no rows, so a screen that derives its empty state or its count from the rows renders “No providers yet” and “0 providers” directly under the LoadError that says the list could not be read, which turns an outage into “nothing configured” (#2211). The empty state waits for a read that succeeded and a count waits for the data it counts; the ListEmptyRow, Table and ListSummary primitives that enforce this are in loading and empty states. A screen’s error stories assert it with expectNoFalseEmpty after expectLoadError.

The control plane’s own message is never swallowed. LoadError prints it beneath the summary. The dashboard’s classification is a helpful gloss, not a replacement — #962 happened because the gloss was the only thing on screen and it was wrong.

A screen that makes one request per row

A screen whose list read is followed by a detail read per row has a failure the single-query screens do not: the list arrives, some of the detail reads do not, and the rows they belong to still have to render as something. Defaulting them to the empty answer is the bug #1461 was filed over — the Complexity Router mapped policyQueries[i]?.data?.tiers ?? [] and so drew a route whose policy had 500’d, 403’d or simply not landed yet in the group headed “No policy yet”, beside a button offering to create the policy it already had.

Keep the four states apart and let each one say what it is:

  • loading — a skeleton for that row, not an empty answer;
  • failed — one LoadError for the group, the affected rows named under it, and no way in: an editor seeded from a read that failed saves a fresh draft over contents nobody has seen;
  • configured and unconfigured — the two real answers.

Routing Rules made the same mistake with its target reads (#2133): targetQueries[i]?.data ?? [] drew a route whose targets were still loading, or had failed, as “No targets yet.” and “0 targets”. It resolves the failure inside the row, not in a group. The route’s name, strategy and labels are known there and only its targets are not, so the card stays in the grid and holds a skeleton, or its own LoadError with a retry for that one read, where the targets would be. Its target count is left out until the read answers. The rule that matters is the same on both screens: the empty copy and the zero count wait for an answer. When route editing lands (#2134), a card whose targets failed must not open an editor seeded from nothing.

Two consequences fall out of this. A count in the screen’s summary counts only the rows that resolved, because a denominator that includes the unread ones states them as empty. And useScreenReady / useErrorState follow the detail reads too — a screen that reports itself interactive while every row is still a skeleton is measuring the wrong moment.

An editor opened from such a row re-reads its own record (refetchOnMount: "always") rather than seeding from the list’s cache, so it has the same two ways of holding nothing and has to render both.

Adding a screen

Add the resource noun to errors.resources.* in every catalog under ui/src/lib/i18n/locales/ (see i18n) and use it as above. The six errors.load.* kinds already exist; a new screen needs no new error copy.

A mutation that fails is a different surface: it is reported where the action was taken, not where the data would have been. For a destructive action that means inside the confirmation, which stays open so the message has somewhere to live — see destructive actions.

A sheet reports its failure in two parts. EditorSheet’s errorMessage is the screen’s own translated lead (“Could not create the key”) and errorDetail is what the control plane said, read with errorDetail() from ui/src/lib/toast.ts. The server answers in English whatever the locale and the dashboard has no table to translate it with, so the lead keeps the sheet readable in every language and the server’s words sit under it in mono, the way a failed toast and LoadError carry them. A sheet that passes only errorMessage renders one line as before; Account’s mint sheet is the first to use both, and the other sheets follow as #2216 reaches them.

One-shot feedback: toasts

Inline messages are for what stays on screen: a field that failed validation, a load that failed. Feedback about something that just happened — a save that went through, a delete the control plane refused, a row toggle that bounced — goes through the toast queue (useToast() in ui/src/lib/toast.tsx, rendered once by <Toaster /> in the shell). A success is a polite role="status" announcement that dismisses itself; a failure is an assertive role="alert" that stays longer, carries the control plane’s own message as its detail line, and can be dismissed by hand. Outside the provider — a story, a test — the hook is a no-op, so a screen never has to know whether the shell is around it. Use t("toast.*") for the titles so every screen says “saved” the same way.

Which outcomes toast

Every useMutation reports its outcome. The rule is where, not whether: the toast carries what the surface that triggered the action cannot, because that surface is gone by the time the answer arrives.

the actionsuccessfailure
a settings screen’s Savetoast — the sticky footer’s “…updated.” flash is gonetoast; the footer no longer keeps a copy
a sheet or dialog that closes on successtoasttoast, plus the inline line the still-open sheet already carried
a delete behind a ConfirmDialogtoasttoast, plus the dialog’s own error line
a row togglenothing — the switch staying flipped is the confirmationtoast; a switch that bounces back says nothing at all
a reveal-once secret (mint a key, issue a token)nothing — the secret on screen is the confirmationthe inline line beside the button

Field-level validation never moves: a value that will not parse belongs next to the field, on screen for as long as it is wrong.

The settings screens invalidate

setQueryData alone left every other reader of the key on the value it already had — the screen looked saved and the rest of the dashboard did not agree. The eleven settings screens now write the response and invalidate the query, so the save is what the next read sees (#1197).

Dashboard loading and empty states

A screen has four things it can be showing: its data, a placeholder for data that is still coming, a placeholder for data that came back with nothing in it, and an error. Before #1180 the dashboard had one shared component for the last of those and hand-rolled the other two, twenty-one and eighteen times respectively.

That is not just inconsistency. The three failures it produced were each concrete:

  • Loading… on one line. Untranslated, so it was the same English word in every locale, and one line tall, so the layout jumped the moment the rows landed. It also said nothing about how much was coming — one row or forty read identically.
  • A screen with no loading state at all. Logs and Rbac rendered their empty shape while the request was in flight, so a slow ClickHouse read was indistinguishable from a deployment that had served no traffic.
  • An empty state that blamed a filter nobody set. ProviderGroups said “No provider groups match.” with no search running. The reader’s next move is to clear a search that does not exist.

The three primitives

StateComponentLives in
in flightListSkeleton, CardGridSkeleton, FormSkeleton, PanelSkeleton, TableSkeleton, StatGridSkeletonui/src/components/LoadingState.tsx
loaded, no rowsEmptyStateui/src/components/ui/empty-state.tsx
failedLoadErrorui/src/components/LoadError.tsx

Loading: the shape of what is coming

Pick the skeleton that matches the content it stands in for — a card grid for a card grid, field pairs for a form, row bars for a list. The point is that nothing moves when the data arrives:

{connectors.isLoading && <CardGridSkeleton cards={3} height={186} min={380} />}

For a list inside a ListTable, put the skeleton inside the table, under the header. The column headers are real information — they say what a row will carry — and taking them away to show a placeholder loses that. Inside the table it goes in a ListStateRow, and so does the empty state: a role="status" or a button placed straight in the table body belongs to no row, which breaks the table for a screen reader (see list tables):

<ListLoadingRow read={keys}>
  <ListSkeleton rows={4} className="p-3" />
</ListLoadingRow>

ListLoadingRow and its sibling ListEmptyRow take the query itself and decide from it, for the reason the next rule gives.

Every shape wraps itself in one role="status" region labelled with common.loading, so a screen reader hears one announcement rather than one per bar, and a story can assert the screen is busy without reaching for a class name. story-harness.tsx exports expectSkeleton for exactly that.

A screen must not render its content shape while a request is in flight. That is the Logs bug: an empty table and a loading table looked the same.

An empty state needs a successful answer. Gate it on isSuccess, not on !isLoading && !error. The two differ exactly when react-query parks a retry: in a hidden tab, or with the browser offline, a failed first attempt leaves the query pending but not fetching, so it is neither loading nor failed. In that window LLM Logs said “Nothing logged yet” about a load that had failed (#1984). For the same reason the skeleton keys on isPending, which covers the parked retry, rather than isLoading, which does not. isAwaiting in ui/src/lib/read-state.ts is that test with one refinement: a query that is disabled (a screen with no org to read yet) is also isPending, forever, so it asks for isPending with a fetch that is running or parked. isEmptyAnswer is the empty-state half. The list-table rows, Table and the summary below are built on the two, so a screen hands over its query rather than restating them.

A read that has not answered holds no rows either

The rule above came from one screen; the review for #2049 found seventeen others breaking it (#2211). A list screen derived its placeholder from the rows (!query.isLoading && rows.length === 0), and a failed read holds exactly as many rows as an empty one. So Providers, Users, Keys, Models, Provider Groups, User Provisioning and the Audit Log said “No X yet”, often beside a create button, directly under the LoadError explaining that the list could not be read. On the evidence screen that is a false statement. An operator reads an outage or a permissions problem as “nothing configured”, and may create a duplicate.

Counts had the same fault in a smaller type: data?.length ?? 0 printed “0 teams”, “All 0” and “0 of 0 routes” while the read was in flight and again after it failed.

The pieces that carry the rule:

  • ListEmptyRow / ListLoadingRow (ui/src/components/screen.tsx) — <ListEmptyRow read={query} rows={rows.length}> renders its EmptyState only on a successful answer. rows is what survived the screen’s filters, so a search that matched nothing still gets its no-match copy.
  • Table takes read beside empty, and the type will not accept one without the other.
  • ListSummary is the count beside a list: <ListSummary data={query.data}> hands its render function the data, so there is no ?? 0 left to write, and renders nothing until the data is held. A summary that also explains the screen passes a fallback with the explanation and no count, as User Provisioning does for its SCIM lead. A failed refetch keeps the rows on screen, so the count keys on the data rather than on isSuccess and stays with them.
  • Anything derived from the list waits too. A card that reads “No spend in this window” when its unit has no row in the rollup only says so once the rollup succeeded (Cost Attribution, #2105); an Export CSV or a “collector config” button that renders the list waits for a list that answered.

A card-grid screen with no ListTable gates its EmptyState the same way: query.isSuccess && query.data.length === 0.

A screen made of cards that each read their own query gives every card all three states (#1976). The Dashboard’s CardRead decides from isAwaiting and the read’s data before it lets a card draw anything: a skeleton in the card’s own shape, then either its own LoadError or the content. The empty copy sits inside the content, where the read is known to hold data, so an empty answer still says so after a failed refresh. When every read has failed holding nothing the Dashboard draws no cards and shows one LoadError for the screen (error states). An average or a rate over an empty window is undefined rather than zero: the Dashboard’s latency and error-rate tiles read “—” with “No requests in this window” under them, where “0 ms” and “0.00 %” claimed a measurement of a quiet deployment.

Empty: what it is, and what to do about it

An EmptyState carries an icon, a title, one sentence of description, and — wherever the screen has a control that would create the missing thing — an actions button that opens it:

<EmptyState
  uxTarget="connectors"
  icon={<Cable />}
  title={t("pages.connectors.emptyTitle")}
  description={t("pages.connectors.emptyBody")}
  actions={<Button onClick={() => setAddOpen(true)}>{t("pages.connectors.emptyAction")}</Button>}
/>

Two rules the wording depends on:

  • “Nothing here” and “nothing matched” are different answers. They want different sentences and different buttons: one offers to create the first row, the other offers to clear the filter. Screens with a search or a filter bar branch on whether one is actually active. Deriving the copy from the row count alone is what produced “No provider groups match.” on a screen with no query.
  • A deployment answer is not an empty state. A control plane with no ClickHouse has not “served nothing yet” — it was never asked to record anything, and no amount of traffic will fill the screen. That is an AnalyticsUnavailableError, not an EmptyState; the Dashboard rendered it as the latter until #1236. Nor is it an outage: LLM Logs, the Dashboard, MCP Logs, Cost Attribution’s spend strip and Account’s usage figures all show it as the informational AnalyticsUnavailable panel rather than a red alert (#1984, #1976, #2016; see error states).
  • No CTA where no action exists. McpOAuth grants are created by a user completing an OAuth flow in a client; Cluster nodes enrol themselves on their snapshot poll. Inventing a button for those would be worse than none. Where the action lives on another screen — a complexity policy needs a route first — link there instead.

Table takes an empty prop rendered in a full-width row, so the placeholder sits inside the table’s border with the column headers above it rather than floating beneath a header row over nothing. It comes with read, the query the rows came from, and renders only once that read succeeded.

An empty result is never routed through LoadError; see error states for why.

Copy and stories

Every string goes through the catalogs as pages.<screen>.emptyTitle, .emptyBody and .emptyAction (noMatchTitle / noMatchBody for the filtered variant), in every locale under ui/src/lib/i18n/locales/; see i18n. Loading needs no per-screen copy — the shapes share common.loading.

Each touched screen’s stories cover all three states with play assertions. story-harness.tsx supplies expectSkeleton, expectEmptyState (which checks the CTA is there) and expectLoadError / expectForbidden, so a story asserts the state rather than a sentence that is free to be reworded.

The error and loading stories also call expectNoFalseEmpty(canvasElement, /No teams yet/): no empty state with that title, and no count of zero anywhere on the screen. It asserts an absence, and absent is also what a screen looks like before it rendered, so it goes after expectLoadError or expectSkeleton. A story whose list renders after some other read has to wait for the list first: Cost Attribution’s spend stories wait for the roster before asserting that no card claims “no spend”.

Dashboard destructive actions

A control that cannot be undone has to ask first, and it has to say what it is about to destroy. Before #1179 the dashboard did this three different ways at once: eight screens confirmed through a hand-rolled Dialog, nine deleted on a single click with nothing between the pointer and the request, and four fell back to window.confirm.

That is not only inconsistent, it is inconsistent in the direction that costs the most. Account rotated a virtual key on one click — the old secret stops authenticating immediately, so a stray click breaks every client using it, with no undo and no warning. Alerting deleted the channel every rule delivered through. Cluster forgot a node’s history. None of them named the row they were about to act on.

The rule

Every destructive action goes through ConfirmDialog (ui/src/components/ConfirmDialog.tsx):

const [target, setTarget] = React.useState<ChannelRow | null>(null);

<ConfirmDialog
  // stable key for the UX stream; never the row's own name (#1730)
  name="alert-channel-delete"
  open={!!target}
  onOpenChange={(open) => {
    if (open) return;
    setTarget(null);
    // an error from a failed delete would otherwise greet the next row opened
    remove.reset();
  }}
  title={t("pages.alerting.confirm.channelTitle", { name: target?.name })}
  description={t("pages.alerting.confirm.channelBody")}
  confirmLabel={t("pages.alerting.confirm.channelConfirm")}
  pending={remove.isPending}
  error={remove.error}
  onConfirm={() =>
    target && remove.mutate(target.id, { onSuccess: () => setTarget(null) })
  }
/>

Five properties are load-bearing:

  • The title names the thing. “Delete channel ops-slack?”, never “Are you sure?”. A confirmation that could be about anything is a click-through, and a click-through is worse than no dialog at all — it trains the habit that gets the wrong row deleted.
  • The description states the consequence in one sentence. What stops working, and whether anything else goes with it. McpOAuth is the model: revoking a grant cascades to its sessions and the dialog counts them; revoking a session does not, and the dialog says so.
  • The dialog does not close itself on confirm. The caller closes it from onSuccess. A mutation that fails leaves the dialog open with the control plane’s own message on a role="alert" line, because closing would drop the only place the failure could be reported.
  • Pending is visible and both buttons are out of reach. The confirm button spins so a slow request does not read as a dropped click, and cancel is disabled too — the request is already on the wire, and a button that looks like it recalls one would be lying. Escape, the scrim and the header’s close button still close the dialog: neither the fetch nor the control plane times a request out, so a delete stuck behind a row lock would otherwise hold the operator in a full-page modal until a reload.
  • Focus stays in the dialog while it is busy. Disabling the button that was just pressed makes the browser drop focus onto <body>, outside the panel whose Tab trap listens for the next key. useModalA11y hands focus back to the panel, and keeps everything outside the topmost modal inert while it is up, so a Tab has nothing behind the scrim to land on (#1998).

The control that opened the dialog carries pending too, scoped to its row. A list has one mutation for every row, so pending={remove.isPending} alone puts a spinner on every row’s control while one delete is out (#2095). Compare the row with the target, remove.isPending && target?.id === row.id, the way Pricing and Limits do.

tone picks the confirm button’s paint: danger (the default) for deletions and revocations, default for something irreversible that is not a removal — key rotation is the case that motivated it.

A save that hands over control of every request goes through the same dialog, with tone="default". Activating a guardrail provider pauses the active one and sends all traffic through the new one, so GuardrailProviders raises the confirmation over its provider dialog. The confirmation names the provider it pauses and says what the failure policy then does to a request (#2163). The provider dialog stays open behind it, so a cancel returns to the form with the fields intact. The confirmation is mounted beside that dialog rather than inside it: the dialog remounts as it closes, and a confirmation inside it would unmount before it could report save_confirmed.

Switching the active provider off is the mirror case and goes through the same confirmation (#2271). It names the provider and says no external guardrail checks requests afterwards, with tone="danger", or that the config-file webhook stays in force, with tone="default". It reports under guardrail-provider-pause. The screen keeps which of the two it raised after the dialog closes: the landing is reported on the closing edge, and a key that flipped back to the activation’s there would file the pause under it.

A publish that changes which prompt reaches live traffic takes default as well, and its confirmation says who the change will refuse. Publishing or rolling back a prompt template version (MakeLiveDialog in PromptRepository.tsx, #2110) lists, as its children, the scopes the version reaches, the variables a request there must send and the ones the live version declared that this one drops, computed from the gateway’s own rules in ui/src/lib/prompt-templates.ts. It warns and never sets confirmDisabled, since the same dialog is how an operator rolls back mid-incident. Its name and direction are latched when it opens: the landing is reported on the render that closes it, and by then the version is the live one, so a direction read again would file a publish as a roll back.

A change to the sign-in policy raises the same dialog when it can shut members out (#2084), on the Single Sign-On screen:

  • Taking a provider out of service confirms as sso-provider-disable with tone="default", since one flip undoes it. Switching a provider back on sends at once. Deleting one keeps sso-connection-delete. When the provider is the last enabled one and the saved policy has password sign-in off, both carry a LockoutNotice as children and the disable button turns danger. locksOutMembers in ui/src/lib/sso-lockout.ts decides it. It reads the saved policy, not the draft on the policy card, and a provider that is already out of service never counts. The notice states only what the control plane enforces: superadmins are exempt from allow_password_login = false, and an account created through a provider has no password, so turning password sign-in back on does not restore it.
  • Turning password sign-in off confirms as sso-password-off, and lists the enabled providers with no stored client secret (secretGap). It warns and never blocks, since a public client has no secret on purpose. A save that also tightens the second factor raises this dialog first and the second-factor one after it, and sends a single request.
  • Turning single sign-on off confirms as sso-single-sign-on-off (#2326) when the org has an enabled provider, with a SsoOffNotice as children. locksOutSsoMembers in ui/src/lib/sso-lockout.ts decides it from the saved policy and the draft: only the flip from on to off counts, and with no enabled provider (none at all, or every one out of service) nobody signs in through one, so the save goes straight out. Turning it on asks nothing. The notice states only what the control plane enforces: the callback refuses every provider of the org while allow_sso is off, and an account created through a provider has no password, so those members cannot sign in until single sign-on is back on or a superadmin sets one. An account that holds a password, such as one made from an invitation, keeps signing in, because password sign-in stays on (the control plane refuses both off). It warns and never blocks, since one flip undoes it. The providers come from the list the screen already read, so a caller refused that list gets no confirmation.
  • The second-factor confirmation counts people with distinctPeople: the memberships endpoint returns one row per grant, so a person holding a role on the org and another on a team is one member.

A save that needs more than one of these asks them in turn, password sign-in first, then single sign-on, then the second factor, and sends one request after the last. The steps are listed once in SignInPolicyCard, and only the last confirmation passes pending: a confirmation that merely moves on runs no request, so it must not report a landing.

The control plane refuses the mirror change, turning passwords off with no enabled provider, but does not refuse these (#2233 tracks that guard). The dialog stays useful once it lands, as the explanation that comes before the refusal.

Three account changes on the Users screen raise it too (#2055, #1893). Each names the account by email and states what the control plane does:

  • Deactivating confirms as user-deactivate with tone="danger". The body says sign-in is blocked, every session the account has open ends at once and the virtual keys it minted for itself stop working at the gateway, and that reactivating brings sign-in and those keys back. Reactivating sends at once, with an icon and a label of its own: it only gives access back, and one click on deactivate undoes a misfire.
  • Deleting confirms as user-delete with tone="danger", raised from the edit sheet, which stays open behind it so a cancel returns to the form. The body says the account leaves every organization and not only the one on screen, what goes with it (its roles and sessions; the keys it minted for itself are disabled, not deleted), and points at deactivating, the reversible way to block a person. A landed delete closes the sheet with it.
  • Granting superadmin confirms when the sheet is saved, since that is when the flag is granted: user-superadmin-grant with tone="default", because it is not a removal and one flip undoes it. The body says what the flag hands over, every organization, team and project and every deployment-wide setting and account. Turning the flag off saves directly, except on the caller’s own account (below).

None of the three self-lockouts is refused by the control plane: a superadmin can deactivate or delete the account they are signed in with, or take superadmin off it, and the last active superadmin can go the same way (#2344 tracks that guard). So when the target is the caller’s own account (useOptionalAuth().user.id), each dialog adds a sentence saying the caller is signed out now, or loses the access they are using, and that only another superadmin can undo it. Taking the flag off one’s own account is the one case where removing superadmin asks (user-superadmin-remove, tone="danger"). The screen never claims an account is the last superadmin: its users list holds only the people with a role in the selected organization, so it cannot know.

EditUserDialog stays mounted whether or not a sheet is open, and the confirmations sit beside the sheet rather than inside it, for the reason the guardrail one does: a save that lands closes the sheet in the same commit, and a dialog inside it would unmount before it reported save_confirmed. The sheet’s own user-edit rows record the press of Save, so a save that only raised the question still reads as a form_submit there; the confirmation’s rows are the ones that say whether anything was sent.

A Dialog paints above an editor sheet. The sheet’s layer is z-[80], the dialog’s z-[85] and the toaster’s z-[90]. Until #2055 the dialog sat at z-50, under the sheet’s own scrim with its action half covered by the panel, which every confirmation raised over a sheet had inherited, the discard prompt included. The assertion is a z-index comparison, because the sheet is inert under the dialog and no query tells the layers apart (expectPaintsOver in Users.stories.tsx).

Mapping an identity-provider group to a role confirms when the grant reaches far (#2078), on the SCIM and the single sign-on screens alike. Both render the shared GroupMappings (ui/src/components/GroupMappings.tsx), so the rule is written once:

  • The form starts on viewer, chosen by name in the component rather than read off ROLES, whose order other screens depend on. After a mapping is written it starts over, so the next one does not inherit an admin grant.
  • admin, or any role at the whole organization, confirms as <kind>-group-mapping-grant (scim or sso) with tone="default", since it is a grant and not a removal. The scope starts on the whole organization, so a mapping left at its defaults confirms. A member or viewer mapping on one team or one project is sent at once.
  • The dialog names which of the two raised it. The title carries the group and the role, the body the scope and, from the screen, when the role arrives (SCIM reconciles on the spot, single sign-on at the member’s next sign-in), and children lists the reasons: what admin is, and that the whole organization is every team and project rather than one. Both reasons appear for admin across the organization.
  • A refusal stays in the dialog. The mutation is reset on cancel, and the form’s own inline error is not drawn while the dialog is up, so the message appears once.

Removing a mapping keeps its own confirmation, <kind>-group-mapping-remove, whose body each screen supplies because what removal does differs: SCIM withdraws the role at once, single sign-on stops granting it at the next sign-in.

A save on the Security screen that loosens the gateway or the dashboard raises the same dialog (#2103), as security-loosen with tone="default", since one more save undoes it. Three edits loosen and nothing else does: virtual-key enforcement turned off, dashboard protection turned off, and each route added to the auth bypass list. loosenings in ui/src/lib/security-loosening.ts decides, comparing the draft with what the store held at the last load or save rather than with the previous keystroke, so a switch flipped off and back on asks nothing. The body lists exactly the changes that opened something, each with what it means, as children, and the title and intro count them. A tightening, such as a route removed or a header required, saves at once, and one request goes out either way. The dialog is mounted beside the form, and the items it lists are kept after it closes so its body does not empty while it fades. The words state the documented meaning of each setting and no more: see the Security screen.

What this is not

window.confirm is not an option. It cannot be styled, cannot be translated — so it is invisible to check:i18n and to the locale catalogs entirely — and in the story runner it is a modal nothing can answer, which means the confirm path of four screens had never been exercised by a test. rg "window.confirm" ui/src now finds nothing outside the check:literals fixtures: the discard guards #1179 left behind came over in #1463, described below.

A hand-assembled Dialog is not a confirmation either. Eight dialogs drifted back into a DialogFooter with their own destructive Button after #1179: ProviderGroups, Models, Pricing, PromptRepository, SkillsRepository, the MCP server delete, the Account key delete and the scope switcher (#1760). Each worked out pending, error and disabled on its own, emitted none of the rows below, and missed every fix made to ConfirmDialog. bun run check:primitives now fails on a DialogFooter holding a "destructive" button; ConfirmDialog itself carries the only waiver. A confirmation that needs input, such as the prompt and skill deletes that ask for the slug typed back, passes the field as children and holds the button with confirmDisabled.

An inline two-step panel is not a confirmation either. The Users sheet deleted through a destructive button that flipped a local flag and swapped in its own body, error line and second button (#1893). No DialogFooter held it, so check:primitives never saw it, and it reported none of the rows below. #2345 tracks teaching the check that shape.

No confirmation at all is the case no check catches. DeleteIconButton looks the same whether its onClick opens a dialog or sends the request, so check:primitives has nothing to match, and the budget and rate-limit deletes on Limits fired on the first click long after #1179 swept the other screens (#1904). What pins it is the story: its cancel step looks for a dialog to dismiss, and a delete that never asked has none.

A confirmation is not a substitute for a reversible action. Where retiring and deleting both exist — CostAttribution — the copy points at the reversible one rather than only warning about the other.

What the dialog reports

ConfirmDialog feeds the UX stream through useFormTelemetry under its name (see UX telemetry). The caller owns the mutation, so the dialog reads the outcome off the props that report it:

RowWhen
form_submit okthe confirm button is pressed
form_submit erroran error arrives after the press that was not already on screen
retry_submitthe confirm is pressed again after a refusal
save_confirmedthe caller closes the dialog after the press, with nothing refused
form_abandon cancelledthe dialog closes without a press

The refusal is read off the press rather than off a pending edge (#1761). A request that settles in the tick it started hands react-query’s pending and error to one notify batch, so pending={true} never renders, and an edge-triggered read reported nothing but the press. It is keyed on the error’s identity, so a retry refused the same way is a row of its own.

save_confirmed is how a landed delete looks from inside the dialog: the caller closes it from onSuccess, so closed after a press with no new error is the success. Passing pending at all is what marks a confirmation that runs a request. The discard prompt passes none, closes the sheet and is done, so it reports no landing.

Two things follow for a call site:

  • Keep the dialog mounted and drive it with open={!!target}. A dialog rendered only while a target exists unmounts on the closing edge and never sees the landing.
  • Reset the mutation on close (remove.reset() in onOpenChange), so a refusal from one row does not greet the next row opened. A dismissal while pending resets a mutation whose answer is still coming, which from inside the dialog looks exactly like a landing, so the dialog disarms its read before it passes the dismissal on: the press is recorded and nothing after it.

A confirmation rendered outside any UxScreenProvider, such as the scope switcher in the user menu, has no screen key and stays silent.

Dismissing a dirty editor

The editor sheets ask a different question — “may I throw this draft away”, not “may I destroy this row” — but they ask it the same way, through useDiscardGuard (ui/src/components/DiscardGuard.tsx). EditorSheet wires it for every screen built on the shell; ModelSheet, ProviderSheet and ProviderGroupSheet call it directly because they assemble their own chrome.

const { guard, close, locked, prompt } = useDiscardGuard({
  dirty,
  saving: save.isPending,
  onOpenChange,
});

<Sheet open={open} onOpenChange={onOpenChange} onDismiss={guard}>
  <SheetHeader title={title} subtitle={subtitle} onClose={close} closeDisabled={locked} />
  ...
  <Button variant="ghost" disabled={locked} onClick={close}>{t("common.cancel")}</Button>
  {prompt}
</Sheet>

Sheet.onDismiss answers synchronously — it was shaped around the browser prompt — so the hook keeps that contract by refusing the dismissal and raising the dialog, then closing the sheet from the dialog’s confirm one tick later. The four dismissal paths (Escape, the scrim, the header’s close button, Cancel) all land on it, and the rules are:

  • A pristine editor closes without asking. A prompt on a form nobody edited is what trains people to click through the one that matters.
  • Cancelling keeps everything. The draft is the caller’s state and is never touched; useModalA11y returns focus to the control that raised the prompt. Only a confirmed discard closes the sheet, and the draft is re-seeded on the next open.
  • One prompt, never a queue. Raising it is idempotent, so Escape twice, or Escape then a scrim click, is still one dialog. The browser prompt serialised for free; a rendered one has to say so.
  • A save in flight refuses dismissal outright. The request is already on the wire and nothing here can call it back, so a sheet that vanished would leave the operator unable to tell whether the mutation landed. Cancel and the close button are disabled while locked rather than silently no-opping, and focus stays inside the sheet the same way it does in a busy confirmation.

Stories answer the prompt through answerDiscardPrompt(true | false) in story-harness.tsx, which finds it by its accessible name — the sheet is still mounted behind it, so sheet() cannot tell the two role="dialog" nodes apart. expectClosesWithoutPrompting() covers the pristine case.

The control names its row too

The dialog naming the row is only half of it. The control that opens it needs the same name, because a list of twelve rows otherwise exposes twelve buttons whose accessible name is the identical "Delete channel" — a screen reader user tabbing the grid hears it twelve times with nothing to tell them apart, and the row’s identity lives only in the visual layout (#1214).

So the aria-label carries the row: t("pages.alerting.channels.deleteAria", { name }), never a bare "Delete channel" and never a template literal — check:literals matches quoted strings only, so an interpolated one would stay untranslated with the gate still green. The copy lives under pages.<screen>.*Aria in every catalog, like all the rest.

The payoff is in the tests: a story selects its delete button by that name rather than by index, so it asserts on the control it means instead of trusting the confirmation dialog to prove the right row was picked afterwards.

Copy and stories

Strings live under pages.<screen>.confirm.* in every catalog under ui/src/lib/i18n/locales/ (see i18n); the title carries the item’s name as an interpolation, so the placeholder has to survive translation.

Each screen’s story clicks the destructive control, asserts the dialog names the row, cancels once to prove nothing was sent, then confirms and asserts the request left. ui/src/pages/story-harness.tsx supplies recording, confirmDestructive and cancelConfirmation for exactly this shape.

Dashboard capability gating

The dashboard gates its controls on GET /api/v1/rbac/effective (#1183).

Before that it gated on nothing. rg 'useCan|capabilities' ui/src had zero authorization hits: every create, edit, delete and toggle rendered for every signed-in account, and the only feedback was the 403 that came back as a generic ApiError after the click. lib/scope.ts said so in its own header — “scope selection, not permission enforcement”. A superadmin-only settings screen loaded, spun, and then rendered a failure that looked like an outage.

The rule

The server decides; the dashboard only repeats the answer early. The guard in crates/rolter-control/src/rbac.rs still runs on every request, so nothing here has to be right for the deployment to be safe. This layer only has to be honest — which is why both uncertain cases fall open.

<GatedButton gate="provider:create" onClick={() => setSheet({ mode: "add" })}>
  {t("pages.providers.add")}
</GatedButton>

gate is the resource:action pair from CAPABILITIES in crates/rolter-control/src/rbac_matrix.rs, spelled exactly as the wire format spells it, so there is no second vocabulary to keep in step.

Three answers, not two

useCan() returns boolean | undefined, and the third one is load-bearing:

answerwhenwhat the control does
truethe pair is in allowed, or the caller is a superadminrenders enabled
falsethe caller’s role does not reach itdisabled, with the required role in the title
undefinedthe query is still in flight, there is no provider above, or the question could not be answered at allrenders enabled

A control that starts disabled and enables itself a request later reads as broken. And a control plane one version behind — one that 404s /api/v1/rbac/effective — must not empty the dashboard: the 403 stays the backstop it always was. ui/src/lib/can.test.ts pins both.

What is gated where

  • The rail. ui/src/lib/nav.tsx carries a resource per leaf, and visibleNav drops a leaf whose <resource>:read is explicitly false — and the group with it once it has no children left. A rail full of entries that all open the same refusal is a worse map of the product than a shorter rail.
  • A leaf reached by URL. A hidden entry is still bookmarkable, so Screen in App.tsx renders the forbidden LoadError up front instead of mounting a screen whose every request is already known to fail.
  • The create controls. Every Add / New / Create / Invite / Generate button on the list screens is a GatedButton on <resource>:create.
  • The per-row controls. Every edit, delete, retire, revoke, rotate, drain and enabled toggle on a row is gated on the same row’s <resource>:update / <resource>:delete (#1258). A Button becomes a GatedButton, a Switch becomes a GatedSwitch, a Combobox becomes a GatedCombobox, and RowIconButton and DeleteIconButton take a gate prop. A screen never gates a control by reading useGate() itself and passing denied down: the control is disabled either way, but only the primitives record the reach for it as a refused_click, so a hand-gated control is invisible to the struggle signal (#1759, see UX telemetry). A control none of them fits gets a gate prop on the nearest primitive, the way DeleteIconButton did, and SwitchRow did for the project-settings switch (#1820). ui/src/components/gated-controls.test.ts fails on a useGate() outside the primitives unless the comment above it carries // use-gate-allow: <reason> — for a genuine non-control use, such as GettingStarted choosing between a link and a GatedButton.
  • The settings cards that save a whole form. A card whose only mutating control is one Save — the sign-in policy on the SSO screen is the model — is gated on that resource’s :update (org_auth_policy:update). The fields stay readable and editable; the Save is the refusal, because a policy value is information a member is allowed to have and blanking the card would leave them guessing why sign-in asks for a code (#1078).
  • The workbench header controls. The prompt repository and the skills repository are master-detail screens, so their mutating controls sit in the workbench header rather than on a row. Rename, Settings, Save new version, Publish and Roll back are all one <resource>:update — publishing moves a version pointer through the same PUT guard as an edit (set_prompt_template_version / set_skill_version in crates/rolter-control/src/crud.rs), and there is no :publish action in the capability table to gate on instead — and Delete is the one control that takes <resource>:delete (#1297). The AsViewer story on each screen asserts all five are refused and name the Admin role.
  • The first-run checklist. GettingStarted on the Dashboard is written for whoever sets the deployment up, so it renders nothing for a caller refused all three of provider:create, route:create and virtual_key:create, and sends none of its three list requests. The Playground step does not count: it takes no role, and one open link above three refusals is not a checklist. While the answer is in flight the card waits rather than showing and then retracting, and an unanswered gate falls open like everywhere else. A 403 from one of its lists hides it too, since a role held below the org the provider list is read at is not something the gate can say first; it is never a forbidden LoadError on the first screen a member opens (#1848).
  • The pending invitations on the Users screen. The section reads invitation:read through useCan() for its presence, the way the checklist does: it waits for the answer, is absent on an explicit false, and sends no request then. The control plane filters the list below the org to the teams and projects the caller administers, so a 403 from the list is an answer too and hides the section instead of raising a forbidden LoadError on a screen a viewer may open. The revoke button is a RowIconButton on invitation:delete; the confirmation still says what revoking at the invitation’s own scope takes when the server refuses a click the gate allowed (#2054).
  • An action a screen takes on its own. The Playground mints its session key as it opens, and minting is my_virtual_key:create, which takes the member role. So the automatic mint waits for the answer and does not go out on an explicit false: a viewer lands on the paste field with a line saying why, rather than on a refusal they never asked for (#2061). Mint key / Renew key is a GatedButton on the same pair. The screen reads useCan() for the automatic mint only, since that is behaviour rather than a control; an unanswered gate still mints, and the 403 stays the backstop.
  • Minting your own key. Generate virtual key on the account screen, in the toolbar and again in the empty state, is a GatedButton on my_virtual_key:create, the pair the Playground’s mint asks. The empty state reads useCan() for its copy only: on an explicit false it says that members mint their own keys and to ask an admin of the project, beside the refused button, instead of inviting a mint the button refuses (#2064).
  • Links into a gated screen. A link the caller cannot follow is a 403 with an extra click. The LLM Logs payload drawer links to the log settings unless logging_settings:read is an explicit false, the rail’s own rule for that leaf, and a refused caller reads who owns the setting instead (#1984). It is a react-router Link rather than a raw anchor, so following it does not reload the dashboard; a story that renders it supplies a MemoryRouter.
  • The deployment-scoped settings screens. Feature flags, the runtime, logging, compatibility, client, model-default, adaptive and security policy, the cluster, connectors, alerting and the MCP logs are wrapped in superadminOnly() (ui/src/components/ForbiddenScreen.tsx). A non-superadmin never mounts them, so they send no request to be refused.

Disabled has to say why

“Disabled” on its own is the same non-answer the 403 was. GatedButton reads the minimum role out of GET /api/v1/rbac/matrix and puts it in the title: t("rbac.needsRole", { role }), or rbac.needsSuperadmin for a pair no scoped role can reach.

It stays a real disabled, never an aria-disabled — a control that takes the click and then explains the 403 has already spent the operator’s attention. The one wrinkle is that the button variants set disabled:pointer-events-none, which also suppresses the native tooltip, so a refused button re-enables pointer events through an inline style. disabled still swallows the click; the RefusedSwallowsTheClick story asserts exactly that.

One query, per scope

CapabilityProvider sits above the shell in App.tsx — above, because the rail is gated too and has to be built with the answer in hand. It runs one query per org/team/project chain (staleTime one minute) plus the matrix for the copy. A scope switch re-keys the query, so a viewer in one org does not carry a cached “no” into the next one.

The stories render as a role from the real table

<Harness role="viewer"> stubs both RBAC endpoints, and it derives the answers from ui/src/lib/rbac-capabilities.json — a generated copy of CAPABILITIES, written by bun run gen:rbac (ui/scripts/gen-rbac-capabilities.ts). ui/src/lib/rbac-capabilities.ts turns that copy into the two payloads the same way the control plane does: matrixFixture() is the port of resource_view, effectiveFor() of allowed_for.

It used to be a table typed out by hand in story-harness.tsx, and nothing compared the two. So it drifted — #1258 found it calling model and model_price org-scoped admin resources when both are deployment-wide catalogs only a superadmin writes, which let two screens gate on model:create and model_price:create, capabilities the control plane does not define, while their stories passed. A fixture more generous than the deployment makes a gating story assert behaviour nobody runs.

ui/scripts/rbac-matrix-source.test.ts is the gate (#1298): it re-parses rbac_matrix.rs on every bun run test and fails when the copy disagrees, naming the pair — model_price:update takes superadmin in crates/rolter-control/src/rbac_matrix.rs, admin in the fixture. Change the capability table, run bun run gen:rbac and commit the JSON with it. The generator parses the Rust source because the control plane emits no artifact to read; #1369 tracks replacing that with a snapshot the Rust test suite writes.

Adding a screen

Give the nav leaf its resource, gate the create control on <resource>:create, gate each row control on <resource>:update or <resource>:delete, and wrap the screen in superadminOnly() if the capability table puts it at scope: "deployment". Cover the roles in a story with <Harness role="viewer"> — the harness stubs both RBAC endpoints from the same table this doc names.

Never hold a control across a re-render

expectRefused and clickWhenEnabled look their button up again on every poll rather than capturing it once, and a story that asserts on a gated control by hand has to do the same.

A screen re-renders while its gate is still in flight: the org/team/project chain resolving re-keys the query behind the screen, which sends it back to its skeleton for a frame, and React builds a new button when it returns. A reference taken before that frame is detached, and a detached node’s attributes never change again — so the assertion waits out its whole budget and reports title: null, which reads as a gate that never resolved even though the live control is refused correctly.

That is what #1670 was: Screens/Rbac › RefusedToAViewer failed identically at 50ms of injected latency and at 900ms, and raising the budget from 1s to 5s did nothing. Harness/Gating stages the ordering deliberately — the control is replaced before the answer it is waiting for arrives — so the two helpers cannot regress to a captured reference.

// wrong: the reference can be detached before the gate answers
const button = await canvas.findByRole("button", { name: /new role/i });
await waitFor(() => expect(button).toBeDisabled());

// right: the lookup is part of the wait
await waitFor(() => {
  expect(canvas.getByRole("button", { name: /new role/i })).toBeDisabled();
});

A refused row control still has to name its row

A gate answers “may I”, never “which one”. The two are separate rules and both have to hold on the same button: <resource>:delete decides whether it is enabled, and the accessible name decides whether a screen reader can tell it apart from the eleven identical buttons under it (#1214). So a row control carries an aria-label interpolated with the row’s own name — t("pages.routing.deleteRoute", { model }), not "Delete route" — and the title falls back to that same sentence when the control is allowed, so the tooltip says what the button does rather than nothing at all.

Naming it with a template literal would type-check and read correctly and still be wrong: check:literals only matches quoted strings, so an aria-label={`Delete route ${model}`} slips past the gate while staying untranslated forever. Route it through the catalogs.

That is also what lets a story select the control it means:

await canvas.findByRole("button", { name: "Delete route gpt-4o" });

rather than reaching for getAllByRole(...)[2] and trusting the confirmation dialog to prove the right row was picked.

Dashboard navigation rail

The operator-facing description of the shell — signing in, the groups, the scope switcher and the three breakpoints — lives in docs/user-docs/concepts/dashboard.mdx. This page is the contributor’s view of the same rail: the code, the invariants and the stories that pin them.

The left rail (ui/src/components/ui/nav-sidebar.tsx) is the dashboard’s primary navigation. It has three shapes, one per breakpoint, and two independent size controls within them.

Breakpoints

The shape is chosen in javascript, not only in CSS: below md the rail is a modal drawer, and a modal owes a focus trap, an Escape handler and a scroll lock that no class can supply. ui/src/lib/use-media-query.ts exports the two queries (BELOW_MD, BELOW_LG), which are tailwind’s md and lg so the javascript and the classes cannot disagree.

ViewportShapeBehaviour
< 768px (below md)off-canvas drawerHidden by default and out of the flow entirely — a closed drawer takes no width. Opened by the hamburger in ScreenHeader, which renders only at this width; drawn over a scrim, with labels; dismissed by Escape, a scrim click, or navigating. Focus, the Tab trap, the scroll lock and the inert screen beside it come from useModalA11y, the same contract Sheet signs.
768px–1023px (md to lg)icon railOn screen, folded to the 52px icon strip. The collapse toggle still works — this only picks the starting state. No splitter.
≥ 1024px (lg and up)full railThe resizable, collapsible rail described below. The remembered width applies here and nowhere else.

Below md the rail’s persisted width is not read and not written: the drawer is sized by the viewport, and a width dragged on a desktop must not decide how much of a phone screen the navigation eats.

Stories: MobileDrawer and TabletIconRail in nav-sidebar.stories.tsx pin the first two rows, including that nothing overflows the viewport at either width. They set their size through src/lib/story-viewport.ts, which .storybook/test-runner.ts turns into a real page.setViewportSize — the viewport addon only sizes the preview iframe inside the Storybook UI, so without that hook a “fits at 375px” story would be measured at 1280 and assert nothing.

The assembled shell

The three shapes above are the rail’s. The shell is the rail plus the screen header that opens it and the route that dismisses it, and until #1239 nothing mounted all three together: the drawer’s open state is owned by App, its trigger lives in ScreenHeader, and the useEffect on location.pathname that closes it is a third place again. Each half had a story; the composition had none.

ui/src/pages/shell-harness.tsx is the fixture that mounts it. It stacks the providers in the order main.tsx does — query client, toasts, a session already in localStorage, a MemoryRouter at the requested route, App — over a fetch stub that answers /auth/me, the RBAC pair, the org/team/project chain, /version and the landing screen’s own data. Anything it does not name falls through to [], so a screen the drawer navigates to lands in its empty state rather than an error. It is a sibling of pages/story-harness.tsx rather than more of it: that module is imported by every screen story, and pulling App into it would pull every page into every one of those bundles.

ui/src/App.stories.tsx uses it for the three widths — Desktop (full rail, splitter, the booted route marked aria-current), Tablet (52px icon strip, no splitter) and Mobile (no rail at all, the hamburger opens the drawer, and picking an entry both navigates and closes it). All three read their copy out of the en catalog rather than repeating it.

The story caught a real defect the moment it existed: the screen’s scroll container in App.tsx was scrollable without being focusable, so on any viewport where a screen overflows, everything below the fold was mouse-only. It now carries tabIndex={0} and a role="region" named by the screen title, the same contract ListTable and CodeBlock already sign.

Collapse

collapsible puts a toggle in the brand row that folds the rail down to a 52px icon-only strip. Labels, the search box, group headings and the version become titles or disappear; the active item keeps its folk-red thread.

Resize

resizable turns the right edge into a splitter. It exists because a fixed width cannot suit every locale: the ru catalog’s labels are consistently longer than en’s, and at the shipped 232px several of them truncate with no way to read the whole label (#950).

  • Bounds — NAV_MIN_WIDTH (180px) to NAV_MAX_WIDTH (420px), exported from the component. Every path clamps: drag, keyboard, and the value read back from storage, so a stale or hand-edited entry cannot restore a rail too narrow to click or wide enough to bury the content.
  • Mouse — drag the edge. Double-click resets to NAV_DEFAULT_WIDTH (232px, the value --sidebar-width carries in index.css).
  • Keyboard — the splitter is a focusable role="separator" with aria-orientation="vertical" and live aria-valuenow/valuemin/valuemax. ←/→ move it 16px, Shift multiplies that by four, Home and End jump to the bounds, and Enter/Space return it to the default. A mouse-only affordance would not be acceptable on a primary nav control.
  • Persistence — the settled width is written to localStorage under storageKey (rolter.nav.width by default), so it survives a reload per browser. Reads and writes are both wrapped: a browser that refuses storage still resizes, it just forgets. The stored value is applied after mount rather than in the state initializer, so the first paint is the same everywhere.
  • Collapse wins. While collapsed there is no splitter at all — a 52px icon rail has no edge worth dragging, and leaving it behind would let a keyboard user stretch a rail whose labels are hidden. The same applies below lg, where the rail is folded or a drawer.

The width transition is disabled for the duration of a drag; animating it would fight the pointer.

Stories: Resizable, DraggedNarrow, DraggedWide and CollapsedHasNoSplitter in nav-sidebar.stories.tsx cover the bounds, the keyboard path, the ARIA contract and the collapsed case.

Keyboard: the shortcut table

Every shortcut the dashboard binds is declared once, in SHORTCUTS in ui/src/lib/shortcuts.ts (#1676). The shell dispatches by walking that table and the reference sheet renders by mapping it, so neither side names a key of its own — a shortcut that works cannot be missing from the sheet, and a row in the sheet cannot name a keystroke nothing listens for.

KeyWhat it doesWhere it lives
⌘K / Ctrl-Ktoggles the command paletteisPaletteShortcut in ui/src/lib/command-palette.ts
/puts the caret in the rail’s search boxisNavSearchShortcut, which focuses NAV_SEARCH_ID
?opens the keyboard shortcut referenceisHelpShortcut, which opens ShortcutHelp
Tab from the top of the pagereveals the skip link, which focuses <main>the first child of the shell in ui/src/App.tsx; not a chord, so not in the table

Adding one is three edits in one file plus its copy: a ShortcutId, a row in SHORTCUTS, and the action in the shell’s ShortcutHandlers — a Record over the id union, so the build fails until the shell handles it. The name goes under shell.shortcuts.items.<id> in every catalog; ui/src/lib/shortcuts.test.ts fails on a locale that is short one, and on copy left behind for a shortcut that has been removed.

/ and ? are ignored while the caller is typing — isTextEntry checks the event target — or the characters would be unwritable in every field in the dashboard, model names and prompts included. The rule is duck-typed rather than an instanceof HTMLElement check so the unit tests can exercise it without a DOM. ? rejects Meta, Control and Alt but not Shift: on most layouts Shift is how ? is typed at all.

The printed chord is the same table’s data. MOD resolves to ⌘ on an Apple keyboard and Ctrl elsewhere (isApplePlatform reads userAgentData.platform and the deprecated navigator.platform, and guesses Ctrl when it has neither), and KbdChord in ui/src/components/ui/kbd.tsx prints it — one <kbd> per key, with the whole group carrying ⌘K as its accessible name. Getting the platform wrong only changes a label: the matchers accept Meta and Control regardless. Stories pin apple rather than letting the runner’s own platform decide what they assert.

The hints beside the controls read their chord from the table too: shortcutChord("palette") inside the palette’s own field, and shortcutChord("navSearch") in the rail’s search box, where it gives way to the clear button once there is a query.

A chord is not a discovery path on its own, so the reference has a mouse path as well (#1697): a footerLinks entry in ui/src/App.tsx, beside the magnifier that opens the palette, opening the same ShortcutHelp. Its title is shell.shortcuts.open interpolated with chordText(shortcutChord("help")), so the entry names the key it stands in for and cannot drift from the table either. The footer rides the rail at every width, which is why ShortcutReferenceFromRail, …FromIconRail and …FromDrawer in App.stories.tsx cover all three.

The skip link is an anchor whose href is the <main> id, but it calls preventDefault and focuses the element itself: letting the fragment land would rewrite the router’s URL, and a fragment target only takes focus in some browsers. <main> therefore carries tabIndex={-1}, which is what makes it a focusable target at all. The link sits inside a <header> so it is content within a landmark — axe’s region rule is on for Shell/App, and a bare anchor above the rail fails it.

ui/src/components/CommandPalette.tsx is not built on the Combobox primitive: that one is a value picker that writes a pick back into a form, while the palette navigates and edits nothing. It owes the same roles all the same — a combobox input over a listbox of options, moved with aria-activedescendant so focus never leaves the field being typed into. The container drops the listbox role while there is nothing in it, since a listbox with no option inside fails aria-required-children.

The record half is deliberately cheap: virtual keys, providers and routes, read with the same endpoints their screens use, only once the palette is open and only for a caller whose capabilities do not say no. There is no search endpoint, and the palette must not grow one without one being built first.

Ranking lives in ui/src/lib/command-palette.ts and is unit-tested there: subsequence matching, so “rr” reaches Routing Rules, with word-boundary and head-of-label bonuses that keep initialisms above incidental hits. Recently visited screens are remembered per browser under rolter.recent-screens; storage is passed in rather than reached for, so the same test covers a browser that refuses it.

Stories: CommandPaletteShortcut, NavSearchShortcut and SkipLink in App.stories.tsx pin the three keys against the assembled shell; Shell/CommandPalette pins the palette’s own keyboard, its records, and its loading, error and empty states; SearchMatchesGroupLabel and SearchMatchesNothing in nav-sidebar.stories.tsx pin the two nav-search bugs #1198 named — a group-label match used to expand the group and then filter every child out of it, and a query matching nothing used to empty the rail rather than say so.

Every leaf is a screen

NAV in ui/src/lib/nav.tsx names the entries; SCREENS in ui/src/App.tsx maps each navigable leaf key to the element rendered at /<key>. The two are one list written twice, and ui/src/lib/nav.test.ts holds them to each other: a nav entry with no screen, or a screen no entry reaches, fails there.

Until #1201 that invariant was assumed rather than checked. App looked the key up in a BUILT set and fell back to a branded Stub screen (“TODO — we’ll come back to this screen”) for anything missing. Every leaf had been built long before, so the fallback rendered nowhere — dead code that still advertised that the rail was allowed to point at a screen which does not exist. Both the set and the placeholder are gone; the test is what keeps the table complete.

The document title

Each screen names the browser tab {title} · rolter (#2002). Before that every screen shared the one <title> in ui/index.html, so tabs, history entries and a screen reader’s page announcement could not tell one screen from another (WCAG 2.4.2).

useDocumentTitle in ui/src/lib/document-title.ts writes it. The name is appended in code rather than taken from a catalog, because it is lowercase in every locale, the tab title included (see the brand guidelines). The title part is the caller’s already-translated string, so switching language re-renders the caller and renames the tab without a reload.

Three places call it:

  • Screen in ui/src/App.tsx, with the header’s own screens.<key>.title. That covers every routed leaf. The refused state renders inside Screen, so a screen the caller may not read keeps its name in the tab, and an unknown or legacy path redirects to a leaf that names itself.
  • Login, with auth.title. Signed out there is no Screen around it.
  • AcceptInvite, with pages.acceptInvite.title once the preview names the org. While the preview is in flight, or when the link is dead, it uses pages.acceptInvite.genericTitle.

A new page rendered outside Screen calls the hook itself. Stories: DocumentTitle, DocumentTitleOnARefusedScreen and DocumentTitleOnAnUnknownPath in App.stories.tsx, DocumentTitle in Login.stories.tsx, and the Loaded and InvalidLink stories in AcceptInvite.stories.tsx. Each blanks document.title in beforeEach, because the title outlives a story and one left behind would pass for the next.

The experimental marker

SUBSYSTEMS in crates/rolter-core/src/stability.rs is the only list of what this build ships as experimental (#1385). It travels on GET /api/v1/version as experimental, and each entry carries the nav_keys it surfaces on — so the mapping from subsystem to nav leaf has one owner, and ui/ never keeps a second copy that can drift out of step with the backend’s.

useStability in ui/src/lib/version.ts turns that into a map of nav leaf key → note. It shares useVersionStatus’s query key, so the shell still makes one request for the two things it reads out of that answer, and it is tolerant on every path that can fail: an older control plane with no experimental field, a network error, or a session still being checked all yield an empty map. The rail then renders with no markers, which is the correct answer rather than an error — a rail that will not render is a far worse outcome than a rail missing a badge.

toNavItem in ui/src/App.tsx sets experimental and experimentalNote on the NavItems whose key the map names. The marker rides along with the individual entry: the grouping is untouched and there is no “experimental” section, which was an explicit constraint of #1386.

Two shapes, because the rail has two widths:

  • Full width — a Badge beside the label carrying shell.experimental, with the build’s own one-line note as its title. The badge is inside the button, so the entry’s accessible name is “Tool groups Experimental” and a screen reader gets the marker without having to find a sibling element.
  • Folded to icons — no room for a word, so the marker is a decorative dot on the corner of the entry’s icon, mirroring the footer’s update hint. The word moves into shell.experimentalItem, the button’s title; on a button with no text content that tooltip is also its accessible name.

The page header deliberately carries no marker. #1386 asked for the nav alone, and the rail is where an operator is choosing what to rely on.

Stories: ExperimentalItems, ExperimentalItemsCollapsed and ExperimentalItemsNarrow in nav-sidebar.stories.tsx cover the two shapes and the narrowest width the rail can be dragged to; ExperimentalMarker and ExperimentalMarkerOnIconRail in App.stories.tsx cover the whole path from the endpoint’s answer to the marked entry.

The tab strip

ui/src/components/ui/tabs.tsx is the in-page counterpart to the rail: an underline strip of role="tab" buttons inside a role="tablist", used by Rbac, Playground and CodeSnippetDialog. It follows the WAI-ARIA tablist pattern, which is a keyboard contract, not styling:

  • One tab stop. A roving tabindex puts tabIndex={0} on the selected tab and -1 on the rest, so Tab walks past the whole strip in one press instead of one per tab. A value matching no tab still leaves the first tab reachable — a strip with no tab stop is a keyboard trap in reverse.
  • Arrows move selection. ←/→ step and wrap at both ends, Home and End jump to the edges. Selection follows focus (automatic activation), which is the pattern’s default for panels that are cheap to render; all three call sites are.
  • Panels are optional. A TabItem may carry id and panelId; the tab then gets aria-controls and the caller’s role="tabpanel" points back with aria-labelledby. Call sites that render no panel omit both and are unchanged — the relationship is opt-in, so adding it to the primitive did not ripple through the screens (#1273).

Stories: WalksWithArrowKeys and LinkedToPanel in tabs.stories.tsx cover the roving tabindex, the wrap, Home/End and the panel wiring.

Every screen is its own chunk

SCREENS in ui/src/App.tsx maps a navigable leaf to the element the shell renders for it, and every entry goes through React.lazy — screen(() => import("@/pages/Foo")), or named(…, "Bar") for the files that export several screens side by side. Vite emits one chunk per lazy boundary, so a screen’s code is fetched when that screen is first opened.

It used to be one bundle. The dashboard emitted a single 1.26 MB chunk, so the first paint of the sign-in screen downloaded the playground, the charts, the highlighter grammars and forty-odd screens the reader had not asked for — on a slow link to a self-hosted deployment that is the whole wait, and the chunks are larger than 500 kB warning had been normalised into build noise that would have hidden the next regression (#1709). Splitting takes the entry chunk to ~374 kB and leaves the build warning-free without touching chunkSizeWarningLimit.

Two screens stay statically imported on purpose: Login and AcceptInvite are the first paint for a signed-out reader, so deferring them would add a round trip to the one screen that cannot spare one.

The shell wraps the screen region in a React.Suspense whose fallback is a ListSkeleton, so the first visit to a screen shows the same role="status" placeholder its own queries use rather than going blank.

Nothing about the air-gapped guarantee changes. Every split chunk is emitted into dist/assets and served by the control plane from there, exactly as the single bundle was — there is no runtime fetch to anything outside the deployment.

ui/src/lib/screens.test.ts holds this: a static import Foo from "@/pages/Foo" added for one new screen pulls that screen and everything it imports back into the entry chunk, the build still succeeds, and nothing in the output says which screen did it. The test names it.

Dashboard theme

The dashboard is dark-only

There is one palette and no theme toggle. ui/src/index.css is that palette: :root holds the shadcn HSL contract, the zinc ramp, the folkloric red accent, the status hues, the surfaces and the type scale, and :root also declares color-scheme: dark so the browser paints scrollbars and form controls to match. There is no light theme to fall back to and no plan for one.

Because of that, dark: variants are banned. Tailwind’s darkMode option is not set, so dark:bg-… and dark:text-… compile to nothing at all — a class that looks like a decision but ships as dead weight. Write the one value you mean:

// no
<p className="text-amber-600 dark:text-amber-500">…</p>
// yes
<p className="text-[color:var(--status-warning-text)]">…</p>

Colour comes from the tokens

Never hard-code a hex, and never reach for a raw Tailwind palette colour (text-emerald-600, bg-blue-500/15, text-red-400). Those bypass the design system: they are not retunable, they are not contrast-checked against the rolter surface, and they drift out of family with everything around them. Reference the token instead — text-[color:var(--status-danger)], bg-[color:var(--red-tint)], border-[color:var(--border-subtle)].

Status colours come in two flavours

Each status hue ships as a pair, and picking the wrong half is a contrast bug:

TokenUse for
--status-success / --status-warning / --status-info / --status-dangerfills: dots, bars, chart series, /15 tints, borders
--status-success-text / --status-warning-text / --status-info-text / --status-danger-texttext: badge labels, inline warnings, delta figures

The fill hues are tuned to carry a shape. As text they are marginal or fail outright — --status-info is 4.34:1 on its own /15 tint over --surface-base and --status-danger is 3.93:1, both under the 4.5:1 WCAG AA floor for body text. Each -text token is the same OKLCH hue and chroma with the lightness lifted until it clears 4.5:1. --status-warning-text equals --status-warning, because the amber already cleared; it exists so a component author never has to know which hue happens to pass.

Check every surface, not just the base one. A /15 tint gets lighter with the surface under it, so a Badge on a card, in a sheet, or on a selected row sits on a different background than the same Badge on the page. The tokens were first tuned against #111113 alone, and axe found what that leaves behind: a success Badge on --surface-subtle was 4.23:1 (#1181). The recorded number is now the worst of eight — each of the four surfaces, bare and tinted:

TokenValueWorst ratio
--status-success-text#38c1635.21:1 (tint over --surface-subtle)
--status-warning-text#f59e0b5.22:1
--status-info-text#6ba1ff4.79:1
--status-danger-text#ff6f664.78:1

The Badge component in ui/src/components/ui/badge.tsx is the reference implementation: /15 tint off the fill hue, label off the matching -text token. Its AllTones story asserts the computed label colour still resolves to the token, so a tone that drifts back to a raw palette colour fails the story tests.

The delta under a StatCard figure (ui/src/components/ui/stat-card.tsx) takes the same -text half, and its colour follows what a change means rather than which way it went. The same up arrow is good news on traffic and bad news on errors, latency or spend, so the two are separate props: trend (up, down, flat) draws the arrow and tone (good, bad, neutral) picks the colour (#1974). Without tone the colour follows the arrow the way a count you want more of reads, so pass it for any figure that is bad when it rises. Leave trend out when the delta is not a comparison with an earlier window: the card then draws no arrow, since one would claim a movement nobody measured. The BadUp and NoComparison stories assert the computed colour against the token, the same way AllTones does for Badge.

When you add a status hue, add both halves, check the ratio against all four surfaces and against the tint the text will sit on, and record the worst number in the comment beside the token.

The same split applies outside the status hues

Three more pairs exist for the same reason, and the rule is identical: the darker half carries a shape, the lighter half carries a glyph.

ShapeGlyphWhy
--zinc-500 (#71717a)--text-subtle → --zinc-450 (#93939e)#71717a is 3.90:1 on --surface-base and 3.08:1 on the --surface-subtle list-header band. #93939e is 6.20:1 / 4.90:1
--red-folk (#b41d21)--red-folk-text (#ff5a3c)the вышивка red is 2.82:1 on the base surface — an ornament hue. The text half is 6.09:1 on base, 4.81:1 on --surface-subtle
--destructive (#b41d21)--status-danger-text--destructive is a surface: bg-destructive, border-destructive/30, bg-destructive/5. It used to be #e53935, which carries --destructive-foreground at only 4.05:1

text-destructive is therefore gone from the dashboard: a destructive label reads text-[color:var(--status-danger-text)]. --danger-text is an alias of the same token.

A translucent tint takes on what is behind it

--red-tint is rgba(255, 64, 23, 0.12), so the colour a reader actually sees is the tint composited over whatever it is laid on — and it gets lighter with that surface, taking the contrast of the text on it down too. --text-subtle is 5.55:1 on the tint over --surface-base and 4.36:1 on the tint over --surface-subtle, under the AA floor. That is how LoadError’s detail line failed axe once a screen put the alert inside a --surface-subtle band (#1725).

A component that can land inside a panel as well as on the page paints --red-tint-opaque instead: the same tint composited onto --surface-base with color-mix(), so it is opaque and its text reads the --surface-base numbers wherever it sits. LoadError is the one that needs it, since it carries --text-subtle; its OnSubtleSurface story renders it inside the lighter panels and axe fails that story if the background turns translucent again. Keep --red-tint for hovers, chips and badges, whose text is chosen to clear AA on the tint over every surface.

Categorical palettes are tokens too

A series colour and an avatar chip are picked by index, not by meaning, so they were written out as arrays inside the components that needed them — five of them, one in raw hex. That array is the same failure as a hard-coded hex everywhere else: not retunable, checked against nothing, and free to drift. The next entry somebody adds has nothing stopping it from being another #b8860b, which is exactly how the gold avatar chip came to carry white initials at 3.25:1 (#1181, #1245).

Both palettes live in ui/src/index.css:

TokensUsed byFloor
--chart-1 … --chart-8, --chart-otherdonut.tsx, scatter-plot.tsx, line-chart.tsx, Dashboard.tsx’s provider bars3:1 — a fill carries a shape
--avatar-1 … --avatar-6the Users.tsx chips4.5:1 against #ffffff — the chip carries initials

An avatar chip takes its entry from avatarColor(id) in ui/src/lib/avatar.ts, a stable hash of the person’s id. The row’s position is never the input: picked by index, a chip changed colour whenever a search or a status tab moved the person up the list (#2059). Hash the id a row is keyed by, not an email the person can change.

The ratio for each entry is recorded in the comment beside it. For the chart hues that is the worst of the four surfaces — a chart can sit on a card, a sheet or a panel, and the donut draws its slices over a --surface-subtle track — which is --surface-subtle (#27272a) for every entry:

TokenValueWorst ratio (#27272a)On #111113
--chart-1--red-600 #e5342a3.44:14.36:1
--chart-2--zinc-400 #a1a1aa5.81:17.36:1
--chart-3--status-info #3b82f64.05:15.13:1
--chart-4--status-success #16a34a4.52:15.72:1
--chart-5--status-warning #f59e0b6.94:18.78:1
--chart-6--red-500 #ff40174.25:15.39:1
--chart-7--zinc-500 #71717a3.08:13.90:1
--chart-8--zinc-300 #d4d4d810.08:112.76:1
--chart-other--zinc-700 #3f3f461.43:1, exempt1.81:1

--chart-1 used to be --red-folk (2.23:1 worst) and --chart-7 used to be --zinc-600 (1.93:1 worst). Both were lifted in #1269. --chart-1 matters most: as the first colour it paints the largest donut slice and the top provider bar. axe cannot catch this, because its color-contrast rule only checks text, so the story gate stays green whatever the chart hues are.

--chart-other is exempt on purpose. It is the tail a donut rolls its long series into, it is meant to be quieter than the named slices, and it never carries meaning alone: its legend row names it and gives its share as text at full contrast. Lifting it past 3:1 would put it between --chart-7 and --chart-2, where it would read as one more named series. Never use it for a series that has no legend row. Any other new entry has to clear 3:1 on all four surfaces.

A component reads the sequence by index and never re-lists the hues:

const PALETTE = ["var(--chart-1)", "var(--chart-2)", …];

Single-series defaults — a sparkline’s stroke, a bar chart’s fill — are a different thing and stay on the semantic token they already use. The categorical tokens are for “the nth of several”, where the only thing the colour means is “not the previous one”.

Never dim a live region with opacity

Container opacity fades the glyphs toward the page background while the background itself does not move, so a 0.55 wrapper takes --text-muted from 7.36:1 to about 3.0:1 and tells assistive technology nothing at all. Two replacements cover every case the dashboard had:

  • a form group gated behind a toggle becomes <fieldset disabled>. The controls inside already carried disabled, so this is simply the truth; axe skips contrast inside a disabled fieldset, and the browser stops the fields from taking input for a feature that is off.
  • an inactive row or card — a disabled key, a blocked account, a retired business unit — gets a quieter band (bg-[color:var(--surface-subtle)]/60) instead. The state is already spelled out by the switch or badge in the row; the fade was only ever decoration, and it cost the row its legibility.

Every story is an accessibility test

ui/.storybook/test-runner.ts runs axe over the whole document after each story’s play function, and fails the story on any violation at any impact (wcag2a, wcag2aa, best-practice; the disabled rules are named with their reasons in DISABLED_RULES — Storybook’s own iframe, plus the three page-level landmark rules a single-component story cannot satisfy, which the two stories that mount a whole page switch back on, see Testing). Adding a story therefore adds a contrast and a semantics check for whatever it renders, in every state it renders — empty, loading and error included.

A failure looks like this:

● Screens/Keys › Loaded › smoke-test
  1 accessibility violation was detected

with the detail printed above it as two tables: the first names the rule (color-contrast, button-name, label, …) and the second gives the CSS selector and the offending HTML. Reproduce a single story with

cd ui && bun run build-storybook
python3 -m http.server 6199 --directory storybook-static &
bun run test-storybook --url http://127.0.0.1:6199 -- -t "Keys"

Read the rule, not the pixel count: color-contrast names the exact foreground, background and ratio it measured, and the fix is nearly always a token swap from this page rather than a new colour. A story may opt out with parameters: { a11y: { disable: true } }, but it has to say why beside it — no story in the dashboard currently needs to.

The identity around the tokens

This page governs the dashboard’s interior: which token a component reaches for and why. The identity those tokens express — the cross-stitch mark, its clear space and minimum size, when --red-500 is the accent and --red-folk is ornament, where the вышивка rule belongs and where it does not, and how the wordmark is set — is documented once for everybody, in the brand usage guidelines on the end-user docs site.

Read it before you place the mark anywhere, add a .vyshivka-rule to a screen, or produce an asset that carries the brand (a slide, a README header, the social preview at assets/og.svg). The rules there are the reason the constraints here exist.

Dashboard design context for agents (impeccable)

impeccable is a design skill for coding agents. Before it touches UI it loads two files from the repository root, and it ships a detector that flags design anti-patterns and drift from the design system after each edit. rolter uses it so every agent that works on the dashboard starts from the same product and visual record.

What is in the repository

FileWhat it holdsWho changes it
PRODUCT.mdUsers, positioning, operating context, constraints, voice. No visual decisions/impeccable init when product truth changes
DESIGN.mdThe visual system: tokens in YAML frontmatter, then prose and named rules/impeccable document after the tokens or primitives change
.impeccable/design.jsonSidecar to DESIGN.md: tonal ramps, shadows, motion, breakpoints, component snippets for impeccable’s live panelregenerated together with DESIGN.md
.impeccable/config.jsonProject settings: the detector hook is on, detector ignoresimpeccable hooks … only, never by hand

DESIGN.md is derived from ui/src/index.css, ui/tailwind.config.js and ui/src/components/ui/. When it disagrees with the code, the code wins: fix the drift, then refresh the file. The reasoning behind each token and its contrast numbers stays in Dashboard theme, and the mark and wordmark rules stay in the brand guidelines. DESIGN.md points at both rather than restating them.

Per-developer state is gitignored: .impeccable/config.local.json (your consent to run the hook), the detector’s caches, live-mode sessions and critique reports.

Setting it up on a machine

The repository declares the plugin in .claude/settings.json (its marketplace under extraKnownMarketplaces, the plugin under enabledPlugins), so Claude Code offers to install it the first time you trust the checkout. To install it by hand instead:

claude plugin marketplace add pbakaus/impeccable
claude plugin install impeccable@impeccable

Start a new session so the skill and its hooks load, then record your consent to run the detector in this checkout:

"$HOME/.claude/plugins/cache/impeccable/impeccable/<version>/skills/impeccable/scripts/impeccable" hooks on

The shared config already has the hook enabled, so on a new machine hooks on only adds the consent to the gitignored local file. The launcher downloads its engine binary from the project’s GitHub releases on first use and refuses to run it unless the .sha256 sidecar matches.

Using it

  • /impeccable critique <target> and /impeccable audit <target> review a screen: critique for hierarchy, clarity and fit with PRODUCT.md, audit for accessibility, performance and responsive behaviour. Findings become issues on the board like any other out-of-scope find.
  • /impeccable polish, harden, clarify, layout, typeset and the rest refine an existing screen. The full list is /impeccable with no argument.
  • /impeccable shape <screen> plans a new screen before any code: a short discovery interview, then a brief the build follows. It and critique stop to ask questions, so the rolter-ui subagent leaves both to the main session and uses audit, adapt, harden and polish.

The detector runs on every Edit/Write to a UI file and prints a short reminder when it finds a mechanical problem: clipped content, a contrast failure, gradient text, a colour that is not a token. A deeper pass runs once when the session stops. Triage a finding the way the skill’s reference/hooks.md describes: fix a real problem; record a confirmed false positive with the narrowest impeccable hooks ignore-value … --reason "…"; ask when unsure. Never add an ignore to get past a finding you have not looked at.

A one-off scan without the hook:

"$HOME/.claude/plugins/cache/impeccable/impeccable/<version>/skills/impeccable/scripts/impeccable" detect ui/src/pages/Models.tsx

Which skills the repository declares

.claude/settings.json and the agent instructions (AGENTS.md, ui/AGENTS.md, .claude/agents/) name only generic, open-source skills such as impeccable: ones anyone can install from a public repository and that do not depend on a single vendor’s product. Skills native to one vendor (Anthropic’s frontend-design plugin, Claude Design and its DesignSync) stay out of the repository; a developer who wants them installs them at user scope. The dashboard’s design record is PRODUCT.md and DESIGN.md in this repository, never a project in an external design tool.

Dashboard code highlighting and markdown

Every structured payload the dashboard shows — a request body in the Logs and MCP Logs drawers, an expanded audit entry’s detail, an effective-config section, a model’s config preview, the client example request, the OpenTelemetry collector document, a copy-as-code snippet, a fenced block in a model reply — renders through one component:

import { CodeBlock } from "@/components/ui/code-block";

<CodeBlock value={json} language="json" label="Request body" wrap />;

Never hand-roll a <pre> for structured content. A raw <pre> gets the keyboard story wrong (a scroll container has to be focusable, #1181), has no copy affordance, and is one more place the palette can drift. CodeBlock owns all three.

The component

PropDefaultNotes
value—the source, verbatim; also exactly what the copy button writes
language"text"one of CODE_LANGUAGES in ui/src/lib/code.ts
label—names the scroll region. Pass one wherever more than one block shares a screen, so the regions are told apart by name rather than by position
wrapfalsesoft-wrap instead of scrolling sideways. On in a narrow drawer, off for a snippet — a line broken mid-token reads worse than one that scrolls (#948)
maxHeight—caps the scroll region; a bare number is pixels
lineNumbersfalsea gutter drawn with a CSS counter
copytruethe copy button, off only where the caller already provides one
density"default""compact" is the tighter scale the log drawers use

The gutter is a CSS counter, never a DOM node. Generated content is not part of the document text, so a selection dragged across a numbered block copies the code and not the numbers — which is why lineNumbers is safe to turn on for a config section an operator is about to paste into a ticket.

The gateway address in a snippet

A surface that hands out a gateway URL (the Getting started request, the Playground’s copy-as-code, the Client Settings example and its placeholder) never builds that URL itself. gatewayBase() in ui/src/lib/gateway.ts is the one place (#2218):

  • it prefers the public base URL saved on Client Settings, trimmed of trailing slashes and of one trailing /v1, which an operator copying an OpenAI SDK example would otherwise double into /v1/v1/chat/completions
  • without one it falls back to /gw on the dashboard’s origin. Never the bare origin: the control plane serves the gateway only under /gw/*, so origin/v1/… is a 404 (#2075)
  • it returns { url, configured }. url is the root without /v1, which the snippet appends; configured says which branch won, and the copy-as-code snippets add their “this is the dashboard’s gateway proxy” comment only when it is false

A component reads it through useGatewayBase() in ui/src/lib/use-gateway-base.ts. The hook loads the saved value through the Client Settings screen’s own query (CLIENT_SETTINGS_QUERY_KEY), so any number of snippets share one request and a save on that screen reaches every snippet already rendered. Client settings are superadmin-only, so the hook asks only once the capability gate answers yes to client_settings:read; every other caller gets the proxy without a 403. The Client Settings screen calls gatewayBase(form.publicBaseUrl) directly instead, so its example follows the field while it is being edited.

Why Prism (refractor), not Shiki

Both were measured as a browser bundle carrying the grammars the dashboard needs (bun build --minify, gzipped):

CandidateMinifiedGzipped
refractor core + 7 grammars56.6 kB20.4 kB
shiki/core + JS engine + 6 grammars, no theme470 kB88.1 kB

Shiki is 4.3× the size before a theme is added, and its accuracy advantage — TextMate grammars with full scope resolution — buys nothing here: the dashboard colours nine token roles, not a hundred scopes. Prism’s grammars are regex tables, they cover csv and log out of the box (which Shiki does not), and refractor ships them as ESM returning hast rather than as a global-mutating UMD bundle.

Everything is vendored through bun add and bundled. There is no runtime network access of any kind — no CDN grammar, no remote theme, no WASM fetch. The dashboard has to work air-gapped.

Lazy by construction

ui/src/lib/code-highlight.ts is the only module that imports refractor, and CodeBlock reaches it through a dynamic import() inside an effect. So:

  • a screen that shows no code never downloads a grammar;
  • the first frame is the plain text, which is also what a screen keeps if the chunk fails to load;
  • a value past HIGHLIGHT_CHAR_LIMIT (100 000 characters) never loads the chunk at all and renders unhighlighted. Prism tokenises synchronously on the main thread; on a multi-megabyte body colour is the right thing to give up, and the content is never truncated.

The chunks are visible in the build output as code-highlight-*.js (~24 kB gzipped) and markdown-*.js (~13 kB gzipped).

The palette comes from the design tokens

ui/src/index.css defines a --code-* token per role, and every one of them resolves to a token that already exists:

RoleTokenReads as
keyword, atrule, builtin, variable--code-keyword--red-folk-text
string, url, attr-value--code-string--status-success-text
number, boolean, constant--code-number--status-warning-text
property, key, class-name--code-property--status-info-text
comment, separator, null--code-comment--text-subtle
punctuation, operator--code-punctuation--text-muted
gutter--code-gutter--text-subtle
log level error / warning / info--code-level-*the matching --status-*-text

Two consequences worth stating. A code block always sits on --surface-subtle, which is the surface those -text tokens were contrast- tuned against, so the numbers recorded in docs/dev-docs/development/dashboard-theme.md hold here unchanged. And a log line that says ERROR is the same red as the failing row in the table it was opened from, because both read --status-danger-text — the colours are one vocabulary, not two.

Never add a hex to a highlighting rule. Add a --code-* token that resolves to an existing design token, and if no existing token fits, add the pair (fill and text) as dashboard-theme.md describes.

Adding a language

  1. Add the name to CODE_LANGUAGES in ui/src/lib/code.ts, and any informal spellings a model or an operator might write to ALIASES beside it (resolveLanguage is fed raw fence tags, so it must never throw).
  2. Add the display name to LANGUAGE_NAMES in ui/src/components/ui/code-block.tsx — it is the accessible name of the scroll region, and it is a code or a proper noun rather than copy.
  3. Import the grammar in ui/src/lib/code-highlight.ts and add it to GRAMMARS. refractor resolves each grammar’s own dependencies, so import the language, not its graph.
  4. Check the token classes it emits against the --code-* rules in index.css. Anything unstyled falls back to --code-plain, which is correct but colourless; add the class to the nearest role group if it means something a reader should see.
  5. Add a story to code-block.stories.tsx asserting the token kinds that make that language worth colouring, and extend the round-trip case in ui/src/lib/code.test.ts.

The bundle grows by the grammar, so the list is closed on purpose: add a language because a surface displays it, not because it might.

Markdown in the Playground

The Playground renders model output as markdown (#955), through ui/src/components/Markdown.tsx and ui/src/lib/markdown.ts. Fenced blocks go through CodeBlock, so they are highlighted and copyable, and a raw/rendered toggle in the column header switches to the literal characters for when the question is what the model actually emitted.

Model output is untrusted input. Whatever is in the prompt, a retrieved document, or a tool result can steer it. The rule that makes this safe is that no HTML is ever produced:

  • marked is used as a lexer only — marked.lexer(), never marked.parse() — and its tokens are mapped onto a small tree with no node that can carry markup. Markdown.tsx renders that tree as React elements, so there is no dangerouslySetInnerHTML in the path and no sanitiser to keep in step with a parser.
  • html tokens become literal text: <script>…</script> renders as the characters an operator can read, and nothing runs.
  • hrefs are allowlisted to http, https, mailto and relative URLs. javascript: and data: keep their label and lose their link. A rendered link carries rel="noreferrer".
  • images are never emitted as <img>. A remote fetch would breach the air-gapped guarantee and would tell a third party that the reply had been read; the alt text renders instead.

Partial input is normal — a reply is rendered on every SSE chunk — so the parse is total: an unterminated fence is treated as the code block it is about to become, and ui/src/lib/markdown.test.ts asserts that every prefix of a reply parses without throwing.

Dashboard dropdowns: the Combobox primitive

Every dropdown in the dashboard used to be a native <select>, wrapped by a Select primitive. That wrapper styled the closed control only; the open list was drawn by the operating system, so it ignored the design tokens entirely — wrong font, wrong colours, no dark mode, no grouping, no secondary line. And a native select has no type-to-filter, only first-letter jumping, which makes a fleet of provider/model addresses unusable (#968).

ui/src/components/ui/combobox.tsx is the one dropdown the dashboard has now. The Select wrapper is gone, and with it the risk of a screen growing a second kind of dropdown: there is nothing left to reach for.

Using it

import { Combobox, type ComboboxOption } from "@/components/ui/combobox";

const options: ComboboxOption[] = [
  { value: "round_robin", label: "round_robin", description: "even split" },
  { value: "cache_aware", label: "cache_aware", group: "cache" },
];

<Field label={t("pages.routing.strategy")}>
  <Combobox options={options} value={strategy} onChange={setStrategy} />
</Field>;
PropWhat it is for
options{ value, label, description?, group?, disabled? }[]
value / onChangecontrolled; "" means nothing selected
placeholdershown while nothing is selected; defaults to common.combobox.placeholder
clearableadds an × that resets the selection — for an optional field
sizedefault matches Input; sm is the compact toolbar control the screens wrote as h-8 text-xs
classNamewrapper layout only (margins, width) — the control’s own height comes from size
listClassNamethe popup, mainly to widen it past the control
titlenative tooltip, for a compact control whose label sits elsewhere

description is the secondary line under the label. The Playground’s model picker uses it for what owned_by says, which the native control could not render at all.

group puts the option under a header. Options are laid out in the order they arrive, so sort them into their buckets before passing them in — the primitive starts a new section every time group changes.

Labels and errors come from Field, as with every other control: Field clones id, aria-describedby and aria-invalid onto the combobox, which forwards all three to its input.

What it owes the keyboard

The pattern is the APG editable combobox with list autocomplete. The input is the combobox: DOM focus never leaves it, and the active option is named with aria-activedescendant rather than by moving focus. That is what makes it safe inside a Sheet or a Dialog — the modal Tab trap in lib/modal-a11y.ts sees focus stay in the panel, because the popup never takes it.

KeyBehaviour
↓ / ↑opens the popup; then moves the active option, wrapping, skipping disabled ones
Home / Endfirst / last enabled option of the filtered list
Entercommits the active option; never submits the surrounding form
Escapecloses the popup and restores the selected label, leaving the value alone — and stops there, so the Sheet around it stays open
Tabcloses the popup and moves on without selecting
typingfilters on substring, case- and diacritic-insensitive, over label, value and description

Opening empties the field so the next keystroke starts a filter rather than editing the selected label at wherever the caret landed; the selection stays visible as the placeholder and as the ticked row.

Two structural rules are not cosmetic, and axe fails the stories when either is broken:

  • The empty-result message is a sibling of the listbox, never a child. A listbox may only own options and groups; a bare paragraph inside one is an aria-required-children violation.
  • The scroll lives on the listbox, not on the popup around it. A scrollable plain <div> is a scrollable-region-focusable violation, because axe cannot see that the arrow keys on the combobox are what scrolls it.

Group headers are listbox > group > option with aria-label on the group — the only shape ARIA allows a header in.

Two more choices exist so a combobox does not make the screen around it harder to query, for a test or for a screen reader:

  • The result-count live region carries aria-live but not role="status". The role would put a second status node on every screen that has a dropdown, and the screens whose own notice is a role="status" look for it by role.
  • The listbox is named common.combobox.options, not after the field. Naming it after the field would put two nodes with the same accessible name on the page, and getByLabelText("Provider") would stop being unambiguous. The combobox is announced immediately before the list, so the context is already there.

Driving one from a story

userEvent.selectOptions only works on a native <select>. Use pickOption from ui/src/pages/story-harness.tsx, which opens the popup and clicks the row by its accessible name:

await pickOption(dialog.getByLabelText("Provider"), "vllm-cluster");

To assert what is offered rather than pick one, openOptions opens the popup and hands back the listbox — the options are not inside the control, so within(combobox).getAllByRole("option") finds nothing:

const offered = within(await openOptions(canvas.getByLabelText("Customer")));
await expect(offered.getByRole("option", { name: "Acme" })).toBeInTheDocument();

Note that a combobox’s value is the option’s label, not the value that goes on the wire — toHaveValue("openai-prod"), not toHaveValue("prov-1").

Copy

The three strings the primitive owns live under common.combobox.* in every catalog in ui/src/lib/i18n/locales/: placeholder, clear, noMatches, plus the pluralised results that the polite live region announces after each keystroke so a screen-reader user hears how many options are left.

Stories

ui/src/components/ui/combobox.stories.tsx covers the render states — default, empty, grouped, long list, clearable, disabled control, disabled option — and asserts the behaviour rather than eyeballing it: listbox semantics and aria-controls wiring, named groups, filter-as-you-type, the no-match message, arrows plus Enter, Home/End, Escape reverting, a disabled option refusing both click and Enter, and clearing. Every one of them also runs the axe gate, which is the point of the issue this primitive closes.

Long lists

Past 120 filtered options the popup renders a window of rows rather than all of them (#1579). Two things make that cheaper here than a virtual list usually is:

  • The heights are known, not measured. A row is tall exactly when its option carries a description, so measure() computes every offset from the data in one pass. The same numbers are applied to the rows as an inline height, so the arithmetic and the layout cannot drift — a row is what the table says it is, rather than the table being a guess at what the classes produce. Changing a row’s padding or type scale means changing ROW with it, and WindowRowsMatchTheirMeasuredHeight fails when they disagree.
  • The window never drops the active option. Whatever the scroll says, the row named by aria-activedescendant is rendered, because a name pointing at an unmounted node announces nothing at all — the one regression #968 must not have. TheActiveOptionStaysMounted walks End, Home and an arrow to prove it.

The rows above and below the window are one role="none" box each, so the listbox still owns nothing but options and groups. A group whose header has scrolled out keeps its wrapper and its aria-label, so a run of rows is still announced under the right name.

Below the threshold nothing windows: filtering narrows a list to a handful in two keystrokes, and the plain path is the one every other story exercises.

Dashboard form primitives

A sheet in the dashboard is a long, dense form: forty-odd controls in six collapsible sections, most of them a label, a control and a conditional error. Field (ui/src/components/ui/field.tsx) assembles one of those rows on a screen. A sheet needs the same pieces laid out by hand — two controls on a row, a padlock beside an input, a chip list under a group label — so the pieces themselves are components.

They live in ui/src/components/ui/ beside the other primitives, and their stories are grouped under Forms/ in Storybook.

PrimitiveFileWhat it is
FieldLabelfield-label.tsxthe compact label a sheet row carries, with the optional required marker and (i) note
FieldErrorfield-error.tsxthe message under a control, carrying the id the control points at
describedByfield-error.tsxjoins the ids a control actually has into one aria-describedby
FormSectionform-section.tsxone collapsible group of fields, open state owned by the caller
Segmentedsegmented.tsxa two-to-four way choice rendered inline as a radiogroup
LockButtonlock-button.tsxthe padlock toggle beside a parameter or header
ChipGroupchip-group.tsxa multi-select over a short, fully visible list
SwitchRowswitch-row.tsxa boolean as a full-width row: title, hint, switch
SettingsPanelsettings-panel.tsxa titled group of settings controls that can be switched off as a block

Which one to reach for

  • A label, a control and its error on a screen is still Field. It generates the control id, binds the label and wires aria-describedby itself, and none of that has to be repeated by hand.
  • A sheet row that lays its own controls out uses FieldLabel + the control + FieldError, and passes describedBy(hintId, !!error && errorId) to the control so the hint and the reason are both announced.
  • A choice with more than four options, or one long enough to want filtering, is a Combobox and not a Segmented — see Dashboard dropdowns and the Combobox.
  • A list long enough to need filtering is a Combobox too, not a ChipGroup. The chips exist because a handful of teams is faster to pick from when all of them are on screen.
  • A titled group of settings on a deployment-settings screen is a SettingsPanel, not Card. The two are easy to confuse and used to be worse: ModelSettings and Performance each declared a local component called Card that had nothing to do with ui/card.tsx’s (#1682). Card is a bare bordered surface with CardHeader / CardTitle / CardDescription / CardContent parts you compose yourself; SettingsPanel is the settings shape — title, one explanatory line, and a control row that dims as a unit.

What they already guarantee

Each primitive owns an accessibility detail that is invisible on screen and easy to lose when the shape is retyped in the next sheet:

  • FieldLabel binds its <label> with htmlFor, or names a whole group through id + the group’s aria-labelledby. A label bound to nothing looks correct and leaves the control unnamed.
  • FieldError renders nothing at all when there is no error, and carries an id so the control can describe itself with it (#1527).
  • FormSection’s header is a real <button> and its (i) sits beside it, not inside: a button inside a button is invalid HTML and an axe nested-interactive failure (#1201). A collapsed section renders no body, so a closed section holds nothing tabbable.
  • Segmented is a radiogroup of real buttons — tabbable, Enter-activated, exactly one aria-checked.
  • LockButton reports aria-pressed and names both the state and what a press will do, because the padlock icon says neither.
  • ChipGroup is one named group of aria-pressed toggles, and says none available rather than rendering an empty row.
  • SwitchRow names its switch after the row title. Handed a gate, it is refused the way GatedSwitch is and must name itself with control (#1820).
  • SettingsPanel groups its controls in a <fieldset disabled> rather than a faded <div>. Fading a live div drags its labels and hints below 4.5:1 while telling assistive tech nothing (#1181), and a reader who tabs into a group that looks off should find it genuinely off.

Every one of those is asserted in the primitive’s own story, so a rewrite that drops one fails the story rather than shipping.

Footers and bodies on small screens

The buttons that finish a form live in the overlay’s own file, beside the panel, not in this table. Two of them decide whether a form can be finished on a phone or in a short window at all (#2003).

SheetActions (ui/src/components/ui/sheet.tsx) is the button row inside a SheetFooter. Its children are Cancel and then the primary action. Its start slot takes anything outside that pair: ProviderSheet’s connection test, which pins itself left with mr-auto, or ModelSheet’s line naming why Save is disabled (#1265). Below sm the sheet is the whole screen and cannot be scrolled sideways, so the row becomes a column. start goes on top, and the pair gets the bottom line to itself with the primary action last, taking the width Cancel leaves. From sm up it is one row that wraps rather than overflows. Every sheet footer uses it. A sheet that writes its own flex justify-end row brings back the single line that pushed ModelSheet’s Save into the gutter and ProviderSheet’s connection test 151 px off the left edge in Russian.

ModelSheet hides its disabled-save reason visually below sm and keeps it as the button’s description and a live region. The error summary directly above the buttons already starts with the same line, and a second copy squeezed beside them is what pushed Save into the gutter.

DialogBody (ui/src/components/ui/dialog.tsx) holds a dialog form’s fields, between DialogHeader and DialogFooter. A panel with a body caps itself at the window’s height and only the body scrolls, so the title, the close button and the primary action stay on screen at 640×360. A dialog with no body keeps its natural height, and the overlay scrolls the whole panel, top edge first. The panel is never capped without something inside it to shrink, since that would push the footer out past the panel’s own border. Use DialogBody for any dialog with more than a couple of fields. A hand-written max-h-[65vh] overflow-y-auto body is the shape it replaced in GuardrailRules, GuardrailProviders, Plugins and McpManagement.

DialogFooter wraps as well. The primary action is its last child, so a wrapped footer leaves it at the bottom right, where it was.

Copy

The primitives carry the small amount of copy they own under common.* in the catalogs — common.aboutField, common.lock.*, common.noneAvailable. A screen’s wording stays in the screen’s namespace and arrives as a prop.

The guard

bun run check:primitives (ui/scripts/check-ui-primitives.ts) is what keeps this page from being advice. It runs in the ui, storybook, docs job and fails on six things: a bare <select>, a raw <pre>, a window.confirm/alert/ prompt, a component re-declared under a name src/components/ui/ already exports, the same element markup hand-written in three or more files, and a DialogFooter holding a "destructive" button, which is a confirmation assembled by hand rather than taken from ConfirmDialog (see destructive actions). The fourth is this page’s rule — #1044 sat undiscovered for months because nothing looked, and seven primitives stayed trapped in one sheet’s file.

The shared names are read out of src/components/ui/*.tsx rather than listed in the script, so a primitive added tomorrow is covered the day it lands.

Repeated shapes

The name rule only catches a duplicate that happens to pick the shared component’s name. It cannot see markup re-implemented inline under no name at all, which is what #1658 found: five sheets rendered the footer failure line as a byte-identical <p className="px-[22px] pt-2.5 text-xs text-[color:var(--status-danger-text)]">, one of them carried role="alert" and four did not, and the check reported the tree clean. So there is a fifth rule that matches a shape rather than a name (#1686).

It normalises every JSX element to its tag plus its className with the classes sorted, and fails when the same pair is written in more than two files. Two knobs keep it from flagging ordinary Tailwind:

  • intrinsic elements only. <div>, <section>, <button> — never <Card> or <GatedButton>. A shared component rendered with the same className in five screens is the primitive doing its job, and flagging it would punish the composition this page is asking for.
  • at least four classes, at least two of them arbitrary values. rounded-[10px], text-[color:var(--text-subtle)], max-w-[840px] are the design system spelled out by hand; flex items-center gap-2 is a sentence in Tailwind and repeats in eighteen files for good reasons. Both numbers were tuned against the tree: at four and two, the #1658 footer line is caught and not one generic flex row is.

A computed className={cn(…)} is not compared at all. Its value depends on props, so two spellings that look alike may render nothing alike, and guessing there would be wrong in the direction that costs trust.

The fix is to extract the primitive. When that has to wait, the shape goes in ui/scripts/repeated-shapes-allowlist.ts with the reason a reviewer accepted one more copy — the same shape as literals-allowlist.ts, and for the same reason: the check deletes an entry the moment the duplication drops back to two files, so the list can only shrink, and a stale entry fails the run rather than quietly covering for a copy that came back. The eight entries it carries today came from the rule’s first pass and are tracked in #1711.

Three things are exempt, by rule rather than by filename:

  • anything under src/components/ui/ — a primitive necessarily contains the element it wraps, and CodeBlock is the <pre>;
  • *.stories.tsx and *.test.ts(x), which are fixtures rather than shipped UI, the same carve-out check-literals.ts makes;
  • a file that imports the shared component and wraps it. Adapting Dialog as BaseDialog under a local Dialog is reaching for the primitive, not duplicating it, and failing that would push screens away from the shared component instead of towards it.

A case the rule is genuinely not about carries an inline waiver on the comment block directly above it:

/* ui-primitives-allow: prose with its newlines kept, not a payload — it wraps
 * rather than scrolling sideways, so CodeBlock's copy button and highlighting
 * would both be answering a question nobody asked */
<pre className="whitespace-pre-wrap">{reply}</pre>

The reason is mandatory — a marker with nothing after the colon fails the run, because an unexplained waiver is indistinguishable from the bug. Waivers live at the point of use rather than in a central allow-list file so they cannot outlive the code they excuse, and every one is printed on every run so the set stays visible instead of growing quietly.

Draft state for a settings form

A settings screen saves its fields as one request, so it cannot tell a pristine form from an edited one by looking at the Save button. useDraft (ui/src/lib/use-draft.ts) holds the two copies such a screen needs, what the server held at the last load or save and what is being edited, and answers the questions Save, the field markers and Discard ask:

const { draft, saved, changed, dirty, set, reset, commit } = useDraft(source, EQUALS);
  • source is the query’s data mapped into the form’s shape. The first load seeds both copies, and a refetch behind an edit never takes the edit away.
  • changed names the fields whose value differs from saved. EQUALS is a module constant that gives a field its own comparison, so a blank line in a list, or the space around a colon, is not an edit. A field left out is compared with Object.is.
  • commit(next) adopts what the server answered as both copies after a save, and reset() is Discard.

The reducer and changedKeys are plain functions with unit tests beside them, since the tree has no React test renderer. The Security screen is the first consumer. The leave guard, the shared field errors and the saved-at line that #2214 asks every settings screen to carry are not here yet; they belong beside this hook rather than in each screen.

Still to do

ModelSheet is the only consumer today. ProviderSheet, ProviderGroupSheet and EditorSheet solve the same layout problems separately and should move onto these primitives; that migration is #1658, tracked separately from the extraction so each sheet can be diffed on its own.

Dashboard list tables

Most list screens (Virtual Keys, Users, Model Providers, Provider Groups, the Model Catalog, Alert History, MCP Logs, OAuth Grants and Auth Sessions) draw their rows with the list-table primitives in ui/src/components/screen.tsx. They are divs laid out as a CSS grid, one template shared by the header and every row, so a column cannot drift between the two.

The grid only draws the columns. Until #2000 nothing in the markup said “table”, and a screen reader read every header and cell as a generic node: no table navigation, no column header announced with a cell, and a sort direction shown only by an unlabelled arrow. The roles below are what make the grid a table to assistive technology (WCAG 1.3.1).

PrimitiveRoleWhat it is
ListTabletablethe bordered, sideways-scrolling frame; label names it
ListHeaderrowgroup holding one rowthe header band; its className lands on the band
(inside ListTable)rowgroupthe body: every child of ListTable that is not the ListHeader
ListRowrowone data row, on the same grid template as the header
ListHeaderCellcolumnheadera column’s visible heading
SortLabelcolumnheader holding a buttona sortable heading, carrying aria-sort
ListActionsHeadercolumnheaderthe heading over a row’s buttons: no visible text, a name for screen readers
ListCellcellone column of one row
ListStateRowrow holding one cellwhat the body shows instead of rows: the loading skeleton, the empty state
ListLoadingRowa ListStateRow, or nothingthe skeleton row, shown while the screen’s read is awaiting an answer
ListEmptyRowa ListStateRow, or nothingthe empty row, shown only once the read succeeded with no rows

ListTable puts the body rowgroup in itself, the way a browser puts a <tbody> round rows written straight into a <table>, so a screen only writes the header and the rows. It tells the header apart by type, which means the ListHeader has to be a direct child.

Building a table

  • Name it. label is required and becomes the table’s aria-label. Pass the screen’s own title, t("screens.<key>.title"). The table is also the focusable scroll frame (#1181), so the name is what focus announces.
  • Every child of a row is a cell. A ListHeader holds ListHeaderCell, SortLabel or ListActionsHeader; a ListRow holds ListCell. A bare span in a row is read with no column header.
  • Replace an element, wrap a component. Where a row used to hold a span or div, change the tag to ListCell and keep its classes: the cell becomes the grid item and nothing moves. Where it held a component (a Pill, a Badge, a Combobox, a Switch, a RowIconButton), wrap it in <ListCell className="grid">. A plain block cell would shrink a pill to its text and a button to its icon; grid lays the component out exactly as it was when it sat in the row’s grid itself, stretched across its column. Add min-w-0 when the component’s own root carried it, or long content widens the column.
  • Name the button column. A column of row buttons takes a ListActionsHeader, never an empty <span />: a column header with no name is announced as an empty column and fails axe empty-table-header. It reads common.rowActions by default; a table whose buttons take more than one column passes label so each has its own name (Auth Sessions has colRenew and colRevoke).
  • A row that opens something holds a control for it. A ListRow is a row, which takes no focus, so an onClick on it is a mouse shortcut and nothing more: without a button inside it, Tab walks past every row (WCAG 2.1.1). MCP Logs is the model (#2022). Its last column is a ListActionsHeader labelled analytics.details, and each row’s chevron button is named after the event (tool, server and time, since a tool repeats down the page). The button stops propagation so the row’s own click does not fire a second time, and its story opens the drawer from the keyboard and asserts, in waitFor, that focus moves into the drawer and back to the button on close.
  • Loading and empty go in a ListStateRow. A skeleton’s role="status" or an empty state’s heading and button placed straight in the body is content no row owns, which a screen reader reads outside the table and axe fails as aria-required-children. The header stays on screen above it, which is the point: see loading and empty states.
  • Hand the state rows the query, not a condition. Write the skeleton as <ListLoadingRow read={query}> and the empty state as <ListEmptyRow read={query} rows={rows.length}>, where query is the screen’s useQuery result and rows is what survived its filters. The rows alone cannot tell a list that is still coming, or one whose read failed, from one that answered with nothing, and a hand-written !query.isLoading && rows.length === 0 put “No providers yet” and its create button under the list’s own LoadError (#2211).

Sorting

SortLabel is the header cell and the button in one. The sort state is aria-sort on the header, where a screen reader announces it with the column: ascending, descending, or none for a sortable column that is not sorted. A column that cannot be sorted carries no aria-sort at all.

The button’s name is the column’s label, which the caller has already translated. It deliberately has no aria-label such as “Sort by name”: a header’s name is computed from its content, so that label would become the column’s name as well, and a screen reader reads the column name before every cell in it. The arrow draws the same state for the eye and is aria-hidden.

Testing

Every story is an axe test, but axe walks straight through a generic span inside a row. A column that lost its ListCell and holds only text passes the axe gate while a screen reader reads it with no header. So ui/src/pages/story-harness.tsx exports expectListTable(canvasElement, name), which checks the table by name: two rowgroups, a header row of column headers, and body rows whose every child is a cell, one per column (or one across the table for a ListStateRow). Each list screen’s loaded story calls it.

The primitive’s own stories, under Display/ScreenPrimitives, assert the roles (TableSemantics), the aria-sort cycle and the hidden arrow (SortIsAnnouncedOnTheHeader), the loading and empty rows (LoadingRowKeepsTheTableWhole, NoRows), and the read they wait on (ParkedReadKeepsTheSkeleton, NoEmptyRowUntilTheReadSucceeds).

The Logs screen is not built from these primitives: it renders a native <table> and gets its semantics from the elements. So does the Roles & Permissions matrix (#2081), which needs what the list primitives do not have: a row header per resource (th scope="row") and a tbody per scope under its own full-width header. Each of its chips pairs an aria-hidden mark with the same statement in sr-only text, so a cell is read as “Read: allowed Create: not allowed …” with its role and resource announced; the Loaded story asserts that name and the mark’s glyph.

Dashboard UX telemetry

The dashboard emits a structural-only event stream to POST /api/v1/ui-events, which lands in the ui_events ClickHouse table beside request_logs. It answers the questions spans cannot: which screens are slow to become usable, where people back out, which forms get abandoned, which error and empty states are actually reached.

The rules the stream lives by are in the ADR (dashboard UX telemetry) and, in code, at the top of ui/src/lib/ux.ts and ui/src/lib/ux-react.tsx. This page is the practical half: where the instrumentation lives and what a new screen has to do.

Instrument the shared component, never the screen

This is the one rule that decides whether the data is complete. A hook wired into a screen is instrumentation that stops at whoever remembered it; a hook wired into the component every screen renders is instrumentation that cannot be forgotten.

Shared componentHookWhat it records
EmptyStateuseEmptyStatea zero-data placeholder was reached
LoadErroruseErrorStatean error placeholder was reached
EditorSheetuseFormTelemetrya create/edit form was submitted, saved, refused or abandoned
ConfirmDialoguseFormTelemetrya destructive action was confirmed, landed, refused or thought better of

EditorSheet backs thirteen screens and ConfirmDialog backs fifteen, so the two of them cover most of what the dashboard does. Both take a required name, which is what stops a new call site from being silently uninstrumented: adding one without a name is a type error rather than a gap nobody notices until the data is queried.

The screen key is not a prop. It travels through UxScreenProvider, mounted once by the app shell, so a sheet rendered outside one is silent rather than mislabelled.

Names are keys, not content

name and target are stable slugs — virtual-key-create, alert-channel-delete, sso-connection-edit. Never a title, never a row’s own name, never anything derived from what was typed. sanitizeKey in ux.ts rejects anything that is not a plausible key rather than truncating it, because a truncated secret is still a secret; the cost of getting this wrong is one missing event, and that is deliberate.

Where a sheet serves both create and edit, name both — custom-role-create and custom-role-edit are different findings, and an abandon rate that mixes them answers neither.

Adding a screen

A new screen calls useScreenReady(ready) with its own primary-data loading flag. Only the screen knows which of its several queries is the one the user is waiting on, so this stays with the screen rather than being inferred centrally — a guess would produce a number that looks authoritative and is not. Take care with a query that is enabled: false: it stays pending forever, so gate it the way Playground does rather than letting it suppress the event.

Everything else — forms, confirmations, empty and error placeholders — comes free from the shared components, provided the screen uses them.

Asserting the events

The events are asserted in stories, through the recording helpers in ui/src/pages/story-harness.tsx:

  • recordUxEvents in the meta’s beforeEach, so a story neither inherits the previous one’s events nor leaves its own behind
  • expectUxEvent(action, target?) waits for one event and hands it back, so the story can go on to assert the screen, the outcome or the duration
  • expectNoUxEvent(action, target?) is the other half, and it is the one that matters for a cancel: a dialog that emitted form_submit on the way out passes every positive assertion and still makes the data say the delete went through
  • uxEvents() for the cases where the sequence is the assertion — a refused save is ["ok", "error"] on form_submit, two rows, not one

The queue is never flushed under a story, so it can be read directly.

Run them with bun run test:stories <file> from ui/.

What duration_ms measures on a form

useFormTelemetry keeps two clocks, and each action reads one of them:

Actionduration_ms is the time since
form_submit (ok and error), retry_submitthe form opened
form_abandon, abandon_dirtythe form opened
save_confirmedthe last save press

The dwell since opening is what separates a misclick from a decision, so every row about the operator’s time in the form carries it. save_confirmed is read the other way: the save-latency query in the observability docs takes it as the wait between pressing save and being told it worked. A clock started at the opening would count the form fill too, and a twenty-second read of a delete dialog would come out as a twenty-second save (#1894). The press clock outlives the form closing, because a caller that closes from its mutation’s onSuccess reports the landing a commit after the form went away (#1761).

The struggle signals

Three of the actions record what the operator could not do (#1731). They are the reason the stream is worth pointing at a dogfood week at all — everything else says what happened, and these say where it went wrong.

ActionEmitted fromWhat it means
retry_submituseFormTelemetry — a submit after a failuresomebody did not understand why the first save failed
refused_clickuseRefusedClick — the disabled-control pathsomebody reached for a control RBAC denies
abandon_dirtyuseFormTelemetry — a close with a draft in itsomebody filled the form in and gave up

form_abandon keeps its old meaning and is now only the clean case — a form closed untouched, which is a misclick rather than a design problem. The two are separate actions rather than one action with a flag precisely so the existing series is still comparable across the change.

A retry is state the hook carries: failed() arms it, the next submitted() spends it. A third save after a success is a fresh attempt, not a retry of a failure two saves ago.

Why a refused click takes a wrapper

A disabled button is inert. The HTML spec has the user agent withhold the click event from a disabled form control, so the one interaction worth measuring is the one the DOM refuses to report — and every alternative to disabled is worse. aria-disabled with a swallowed handler makes a screen reader announce a control that is not one; removing the control takes away the explanation of why it is missing.

So the control stays genuinely disabled and useRefusedClick catches pointerdown in the capture phase on a display: contents wrapper beside it. Capture runs on every node on the event’s path before the target, and a pointer event is dispatched to a disabled control where a mouse or click event is not, so the wrapper sees the reach the button never will. The wrapper generates no box, so the control keeps its place in the parent’s layout.

A disabled control is not focusable, so there is no keyboard path to miss, and nothing is deduplicated: reaching for the same refused control four times is the signal, not noise.

GatedButton, GatedSwitch, GatedCombobox, RowIconButton and a gated DeleteIconButton are wired, which is every shared gated control — and the only way a screen gates one (#1759). A screen that read useGate() itself and disabled a plain button got the same look and recorded nothing: Keys and Models shipped their row edit and delete that way, the exact controls the struggle signal was meant to tell apart. The guard in ui/src/components/gated-controls.test.ts fails on a useGate() outside the primitives unless it carries a // use-gate-allow: <reason> waiver, and RBAC gating states the rule from the gating side.

Naming the control

Every event already carries its screen and the capability that refused it, which says which permission boundary is in the way. The control slug is the part that says which of the refused controls on that screen was reached for — on Users, the difference between “the role grant is gated” and “deactivating is gated” (#1750). So every gated primitive takes a required control — DeleteIconButton as soon as it is handed a gate — the way EditorSheet requires name: a required prop is what stopped form instrumentation drifting, and it does the same here.

The slug is

  • kebab-case <noun>-<verb> — provider-new, alert-rule-toggle, sso-provider-secret-clear. The noun is the thing the control acts on as the screen names it, the verb is what the control does to it.
  • a string literal at the call site — never the button’s label, never a translation, never anything read off the row it sits in. A label changes with the locale and a row’s name is the operator’s content; either one turns the target from a key back into free text. sanitizeKey drops a control key that is not a plausible slug, but the rule is that it never gets that far.
  • unique within its file, so no two controls share a target. The empty state’s call to action is a different control from the header button that does the same thing, and says so with an -empty suffix — provider-new-empty — because “people reach for the empty-state button” is itself worth knowing.
  • stable across copy changes. A slug is a series key: renaming one splits its history in two. A control whose label flips with row state (retire and restore, drain and return) keeps one slug, named for the action it gates.

A slug does not repeat the resource for its own sake — the capability travels beside it, so attribution-new on Cost Attribution resolves to attribution-new:business_unit:create or …:customer:create by itself.

ui/src/components/gated-controls.test.ts enforces the shape from the source: every shipped call site passes a literal, kebab-case slug, and no file repeats one. The component still falls back to its kind — button, switch, row-action — for anything that reaches it untyped, so such a call site is degraded rather than silent: it still records the capability and the screen.

What a refused row carries, and why it is on by default

This one is a privacy-shaped call, so the reasoning is written down rather than left in a review thread. The row records that a control was reached for and refused:

screen  = providers
action  = refused_click
target  = provider-new:provider:create
outcome = error

plus the session_id, org_id/team_id/project_id and app_version every event in this stream already carries, and the user_id the server fills from the session for every event alike. No label, no message, no identity beyond that. It says “this permission boundary is in somebody’s way”, not “this person tried to do something they should not have” — which is the same structural-only bar the rest of the stream is held to, and what makes shipping it on by default defensible rather than surveillance.

sanitizeKey is what holds that line, and it is deliberately asymmetric. The capability is the signal, so one that is not a plausible key drops the event entirely; a control key that is not one — a call site that passed the button’s text — drops only the control and still records which boundary was hit.

Adding an action

The action enum is a ClickHouse Enum8, where the ordinal is the stored value. It is append-only: renumbering an existing value rewrites the meaning of every row already written rather than migrating it. Adding one means

  1. a new clickhouse/NNN_*.sql that re-declares the enum with the existing ordinals untouched and the new value appended,
  2. the value appended to ACTIONS in crates/rolter-control/src/ui_events.rs, in the same order — the_action_list_is_the_enum8_in_ordinal_order pins it, so a value sorted into the middle is a failing test rather than a silent remapping,
  3. the value added to the UiEvent["action"] union in ui/src/lib/api.ts and a named emitter in ux.ts, so a typo at a call site is a type error.

Proving the pipeline end to end

Stories assert what the dashboard emits. They say nothing about whether a row ever reaches ClickHouse, and the client is built so that it never will say: ux.ts swallows every transport failure on purpose, because a dashboard whose save button breaks when analytics 500s is worse than a dashboard with no analytics. A pipeline that has quietly stopped working is therefore indistinguishable, from every screen and every log, from a pipeline nobody is using.

crates/rolter-control/tests/ux_pipeline.rs closes that gap: it posts the payload ux.ts builds to the real POST /api/v1/ui-events, then reads the rows back out of ClickHouse and asserts screen, action and ts. Reading ts back is the only place #1224 — a batch stamped at flush time, collapsing a whole session onto one instant — is observable at all, since a row stamped at ingest is otherwise a perfectly valid row.

It runs wherever both ROLTER_TEST_DATABASE_URL and ROLTER_TEST_CLICKHOUSE_URL are set, and self-skips otherwise. CI sets both: the nextest / doctests job in quality.yml runs a ClickHouse service beside its Postgres one. Locally:

docker run -d --name ux-ch -p 18123:8123 clickhouse/clickhouse-server:24-alpine
ROLTER_TEST_CLICKHOUSE_URL=http://127.0.0.1:18123   cargo test -p rolter-control --features postgres --test ux_pipeline

The test creates the ui_events table itself, from the shipped clickhouse/008_ui_events.sql rather than from a copy — a private copy would let the table under test drift away from the one a deployment gets, which is the class of failure it exists to catch.

Failure modes, and who notices

Every row below was produced by running it, not by reading the code. The middle column is what decides the blast radius: ux.ts treats 401, 403, 404 and 405 as terminal and stops sending for the life of the tab, and treats everything else as transient — dropping the batch it was holding and trying again on the next flush.

What went wrongServer answersClient doesCostWho notices
ClickHouse is down or unreachable500drops the batch, keeps sendingthe events queued during the outage, and nothing after itthe operator: a warn and a counter
ui_events table missing (a data volume older than the DDL)500drops the batch, keeps sendingevery event, for the whole run — the condition never clears itselfthe operator: a warn and a counter
one malformed event in a batch400drops the batch, keeps sendingevery event that shared that flush, not just the bad onenobody
batch over 100 events400drops the batch, keeps sendingthat flush; only reachable if the client’s cap drifts from the server’snobody
session lapsed, or no session401disables itselfeverything from that moment until the tab is reloadednobody
control plane older than #805, or a proxy dropping the route404disables itselfeverything, from the first flush onwardnobody
a proxy rewriting the method405disables itselfeverything, from the first flush onwardnobody
CLICKHOUSE_URL unset on the control plane500drops the batch, keeps sendingeverything, and one request per flush foreverthe operator: a warn and a counter
UX events switched off (logging_settings.ui_events = false)202nothing — this is successeverythingthe operator

“Nobody” is literal where it appears: the dashboard shows nothing, and the control plane does not log a rejected request. A store that cannot take the write is different, because it is a deployment fault rather than a client bug, and since #1747 the server reports it even though the client stays quiet:

  • rolter_control_ingest_failures adds one per lost batch, labelled stream (ui_events, or mcp_logs for the MCP ingest endpoint, which shares the path) and reason (insert for a refused or unreachable store, unconfigured for a missing CLICKHOUSE_URL) — alert on any sustained rate;
  • a telemetry ingest failed warning names the store’s own error, at most once a minute per stream, with a suppressed count of the failures since the previous one. Once a minute rather than once a batch because this is a per-flush path from every open tab, and a warn per batch would bury the log it is meant to be found in;
  • the 500 body says only that the event store did not accept the write. It no longer quotes ClickHouse’s error or the insert URL, which are internal topology on a client-facing response; any credentials in the URL are masked in the log too.

The status stays 500 on purpose: ux.ts reads it as transient, so capture resumes by itself once the store is back. The logic lives in crates/rolter-control/src/ingest_failure.rs.

The last row is the only deliberate one. In a Postgres-backed deployment it is the ui_events column on the logging_settings singleton (migration 0073, #1748): the Dashboard Usage Events switch on Logs Settings, or ui_events in the PUT /api/v1/logging-settings body. PostgresConfigStore projects it into logging.ui_events, which is what the handler reads, so the switch takes effect on the next batch with no restart. rolter-seed --import writes it too, but only when the file names logging.ui_events — the default is on, so a file silent about it must not undo an operator’s opt-out. The same rule holds for the API: a PUT that omits ui_events leaves the stored value alone. Only a TOML-only control plane reads the key from rolter.toml directly.

switching_ui_events_off_answers_202_and_stores_nothing in crates/rolter-control/tests/ux_pipeline.rs pins the row: it switches the stream off through the API, sends a valid batch, asserts the 202 and an empty read-back, then switches it on and watches the same batch land.

Formatting markdown, MDX, JSON and YAML

Every Rust file in this repository is rustfmt-formatted, every TOML file is taplo-formatted, and everything under ui/ is prettier-formatted against ui/.prettierrc. Until #1695 the prose and data files outside ui/ were the one part of the tree nothing owned: docs/dev-docs/, docs/user-docs/, AGENTS.md, README.md and the ADRs were wrapped, padded and bulleted however each author or agent happened to type them. A one-word edit to a hand-padded table showed up in review as a rewritten table.

They are now formatted by prettier too, from a root .prettierrc and .prettierignore.

Running it

just fmt-docs           # rewrite
just fmt-docs-check     # check only, the way CI does

Both call scripts/format-docs.sh, which is also what the prettier (markdown / json / yaml outside ui/) prek hook and the docs formatting (prettier) step in quality.yml’s ui, storybook, docs job run. Never invoke prettier here with ad-hoc flags: the hook, the CI step and a local run all read the same two config files, and a flag that only one of them passes turns into a diff nobody can reproduce.

The prettier version is not pinned in a second place. scripts/format-docs.sh reads it out of ui/package.json, so the whole repository formats with one build and a dependabot bump moves both halves at once.

The style, and how it was chosen

{
  "printWidth": 100,
  "embeddedLanguageFormatting": "off"
}

Derived from the tree rather than imposed on it, the same way ui/.prettierrc was — each candidate was applied to every markdown and MDX file outside ui/ and scored by the size of the diff it produced:

proseWrapembeddedLanguageFormattingFiles touchedLines changed
preserveauto (prettier’s default)1414840
alwaysauto16223942
alwaysoff16222423
preserveoff — chosen1293596

The JSON and YAML outside ui/ were measured separately and cost 2 files and 34 lines, so they are in scope too rather than left as a second unformatted island.

Three things follow from that table.

  • proseWrap stays at prettier’s preserve default. always rewraps every paragraph in the repository — a six times larger diff, and every later prose edit reflows its neighbours, which is the failure this was meant to end rather than a new form of it. Line wrapping in prose is therefore still the author’s choice; what is normalized is structure: table padding, list markers, emphasis delimiters, heading and fence spacing.
  • embeddedLanguageFormatting is off. Prettier’s default reaches inside fenced code blocks and reformats the JSON, YAML and TSX in them. A code block in the docs is a quotation of real code or real config, and rewriting it is wrong in both directions: it made a prometheus.yml snippet disagree with the file it is quoting, and it turned documented JSX fragments such as {keys.error && (<LoadError … />)} into statements with a trailing semicolon. Turning it off also removed the only case where prettier rewrapped prose against proseWrap: preserve — inside a Mintlify <Note> or <Warning>, whose children it was printing as JSX.
  • printWidth is a free choice, so it matches ui/.prettierrc. With proseWrap: preserve and embedded formatting off, nothing in these files is wrapped at all: the diff is byte-identical at 80, 100 and 120 columns. 100 is set so there is one number to remember for the repository, not because the measurement preferred it.

What is excluded, and why

.prettierignore carries the reasoning per entry. In summary: ui/ (it has its own config, ignore file and check step), charts/rolter/templates/ (Go templates that end in .yaml and no YAML parser can read), crates/*/CHANGELOG.md (release-plz regenerates them), CLAUDE.md (a symlink to AGENTS.md), docs/dev-docs/book/ and the other build outputs.

.github/workflows/ is formatted like any other YAML. It was excluded at first because the release-handoff check grepped the workflow files as text, and a needs: [a, b, c] list that prettier reflowed onto several lines failed it without changing anything the runner sees. scripts/check-release-handoff.py now reads the workflows as parsed YAML (#1723), so a workflow can be laid out however prettier likes. actionlint and zizmor still audit what the workflows do; prettier only owns how they are laid out.

The one-time reformat

The reformat is a single commit of its own, separate from the commit that added the tooling, so git blame can be told to walk past it. That is what .git-blame-ignore-revs is for — GitHub reads it with no setup, and git does after one config:

git config blame.ignoreRevsFile .git-blame-ignore-revs

The SHA it lists is the one master got, recorded after the merge (#1741): this repository squash-merges, so the SHA a branch carries is not the SHA that lands on master, and an entry naming a commit master has never seen is ignored in silence, which reads as done while blaming exactly as badly as before.

.github/workflows/ joined the formatted set later, in #1723. That reformat is two hunks in release.yml (a comment’s spacing and the verify-parity needs list), and it landed in the same squash as the rewritten release-handoff check, so that commit is not formatter output alone and is not listed.

Gating only changed files was the alternative and was rejected: a per-file-changed gate leaves the tree permanently half-formatted, so the “reformatted paragraph” diffs keep arriving one file at a time forever, and the hook has to grow a notion of which files are in the club.

Nothing in that commit changes what a page means. It was verified by parsing every one of those files before and after with remark-parse + remark-gfm + remark-mdx and comparing the resulting document trees: headings, link targets, code-block contents, list nesting and every MDX element name and attribute are identical. That is the check that matters for the two navs — docs/dev-docs/SUMMARY.md for mdBook and docs/user-docs/docs.json for Mintlify — since a reflow that moved a link target would break the book build or the docs site silently. The llms.txt tracks the docs nav check was confirmed green on the reformatted tree for the same reason.

Commit conventions

rolter uses Conventional Commits for commit messages and PR titles. CI checks PR titles; the conventional-pre-commit hook managed by prek checks local messages.

Format

<type>(<scope>): <subject>

<body>

<footer>
  • type (required): feat, fix, perf, refactor, docs, test, build, ci, chore, revert
  • scope (recommended): gateway, balancer, proxy, core, store, auth, control, ui, docs, infra, ci, deps, release
  • subject: imperative, lowercase, ≤ 72 chars, no trailing period
  • breaking change: add ! after the scope and a BREAKING CHANGE: footer

Examples

feat(balancer): add precise kv-event cache-aware scorer
fix(gateway): stream anthropic sse without buffering
perf(proxy): reuse pooled client per egress proxy
docs(architecture): document reload-free config propagation
refactor(core)!: rename ModelRoute.targets to upstreams

BREAKING CHANGE: config field `targets` is now `upstreams`.

What BREAKING CHANGE: means after 1.0

Before 1.0 the trailer meant “something moved”, a Rust API included. From 1.0 it means one thing, decided in ADR-0032: a change to a promised surface that ships without a completed deprecation window. All crates share one version, so a single trailer takes the whole product — binaries, wheel and Docker tag — to the next major. It is expensive on purpose, and it needs a written migration note in the release.

It does not apply to:

  • a removal that completed its deprecation window (two minor releases and 90 days for /api/v1/* and for config keys). The window was the notice; the removal ships as an ordinary feat/refactor whose release notes name it;
  • a Rust API change in any crate — they are internal and carry no promise;
  • rolter tracking a change OpenAI or Anthropic made to their own dialect;
  • a subsystem carrying the experimental stability marker, which is the documented exemption.

The rule behind it: if the change could have been deprecated first, it must be, and then it is not breaking.

Issues & PRs

  • Link issues from the body/footer: Closes #123, Refs #123.
  • PR title must be a single valid Conventional Commit line (enforced by CI via amannn/action-semantic-pull-request).
  • Squash-merge so the PR title becomes the commit on master; keeps history releasable and changelog-friendly.
  • Never put a coding agent’s session or remote-connection URL in a commit message, a PR body, or anywhere else — once merged, a commit message can only be corrected with a history rewrite. scripts/check-agent-session-urls.sh enforces this as the no-agent-session-urls prek hook (commit-msg stage) and, in CI, in two places: the no agent session urls (commits) step of quality.yml’s static checks job re-checks every commit the PR introduces, so a --no-verify push or an amend is still caught, and a step of the ci-ok job in ci.yml checks the PR body as the API returns it, not as the event payload froze it (plus the commit range on a dispatched or merge-queue run). The body half has to live in ci.yml because a body edit takes the edited fast path, which skips quality.yml entirely — a body check inside that workflow could never see the edit that introduced a URL. See #1406, #2035 and ci-gating.md.

Tooling

  • .config/commitlint.config.mjs — rules (types, scopes, lowercase subject, 72-char header).
  • prek.toml — fast commit-time hygiene, secret scanning, formatting/linting, commit-message validation, and pre-push test/security gates.
  • Install all configured hook stages with prek install --prepare-hooks. The configuration installs pre-commit, commit-msg, and pre-push shims.
  • Run commit-time checks manually with prek run --all-files.
  • Run the push gate manually with prek run --all-files --hook-stage pre-push.
  • The Rust fmt, clippy, test and cargo-deny hooks skip a commit or push that touches no Rust input; see When the hooks skip Rust.

The commit stage uses prek’s built-in checks plus pinned Gitleaks and Conventional Commit hooks. Project-specific checks require actionlint, taplo, and typos on PATH. The push stage also requires cargo-deny and Bun when UI files are part of the push. Install cargo-nextest for CI-equivalent test execution; the hook falls back to cargo test when it is unavailable.

Issue tracking

Work is tracked as GitHub issues on rolter-ai/rolter, projected onto the rolter board (Projects v2, org project #1). The board is the roll-up: an issue that is not on it is not tracked, and an issue missing its fields is invisible to any grouping by milestone, priority or size.

.github/workflows/project-automation.yml adds every newly opened issue and pull request to the board and seeds the fields it can. Everything else is set by whoever files the issue.

Fields

Status

ValueMeaning
BacklogAccepted but not scheduled. Nobody is expected to pick it up next.
TodoScheduled and ready to start; scope and fields are settled.
In ProgressSomeone — or an agent worktree — is actively working on it.
In ReviewA pull request is open, waiting on review or on ci-ok.
DoneMerged or otherwise resolved.
CanceledDeliberately not doing it; the reason is in the issue.

New issues are seeded Todo, new pull requests In Review. Done and Canceled are applied by the board’s built-in workflows when the item closes, so they are never set by hand.

Priority

Urgent / High / Medium / Low, seeded Medium on new issues. Set it explicitly — unprioritized is a decision, not a default. Urgent means it blocks other work right now, not that it matters a lot.

Area

Which part of the system the work lands in — one value, the dominant one. Matches the Conventional Commit scopes in AGENTS.md, so the field and the eventual PR title agree:

gateway · control · ui · proxy · balancer · store · auth · core · docs · ci · infra · cross-cutting

Labels already carry topic (security, performance, tech-debt); Area carries location, which is what makes “everything queued against the data plane” or “how much UI work is left before 1.0” answerable in one grouping.

Plenty of issues touch two areas — a migration plus a dashboard screen, a gateway change plus its docs. Pick the one where the work actually lives. cross-cutting is for epics and research spikes that genuinely have no centre, not for anything with a second file in it.

Not seeded by automation: a scope guessed from a title would be wrong often enough to be worse than blank. Set it when you file.

Effort

XS (< 1h), S (a few hours), M (~1 day), L (2–3 days), XL (a week or more). Not seeded, because a size is a judgement rather than a default.

Size from the scope the issue actually states, not from the title. A migration drags its bump_config_version() trigger with it; a dashboard string fans out across every i18n catalog; a “research this first” section is most of the cost.

Milestones

Every issue gets one. Propose a new milestone rather than forcing a bad match or leaving it empty.

MilestoneWhat belongs in it
Release 1.0.0Only work that blocks tagging 1.0.0. If you could ship 1.0.0 with it still open, it belongs somewhere else.
Post-1.0 polishReal, wanted work that does not block the tag: dashboard polish, internal refactors, extra providers, anything blocked on an upstream dependency.
Release 2.0.0Post-1.0 capabilities that are their own body of work — subscription-backed provider auth, agent-CLI egress DLP, pluggable custom AI APIs, external secret backends.
Maintenance, CI & DXRepo hygiene, CI hardening, dependency triage, contributor experience.
Research & inspirationSpikes and prior-art surveys that inform the roadmap without shipping anything.
StretchOptional or exploratory scope per ROADMAP.md.

The Release 1.0.0 description promises that everything in it blocks the release. Post-1.0 polish exists so that promise stays literally true — moving a non-blocker there is not a demotion.

Station labels

Two Claude stations work the board concurrently, and station:mac / station:rtx say which one owns an issue or PR. The label is a lock: a session never starts anything carried by the other label, claims an unlabelled issue by labelling it before branching, and hands work over by swapping the label with a comment. The full protocol, including the ownership split by area, is the “Two build stations” section of AGENTS.md.

Relations

Set them where the token has permission:

  • parent / sub-issue for work that belongs under an epic
  • blocked by / blocks for real sequencing dependencies

Not every token can write these. If a relation cannot be set, say so plainly and state the intended link in the issue body (Blocked by #123, Child of #456) so it survives for whoever can.

Filing

gh issue create, then gh project item-add 1 --owner rolter-ai --url <url>. gh project item-list truncates, so confirm membership through the GraphQL projectItems field rather than by grepping the list.

State the problem, where it surfaced (link the PR or the file), and what would count as done. Everything noticed outside the scope of the current task becomes an issue before that task is reported done — see the scope-discipline section of AGENTS.md.

How defaults are seeded, and why they cannot clobber you

project-automation.yml adds every new issue and PR to the board and seeds a starting Status — Todo for an issue, In Review for a PR, since an open PR is by definition waiting on review — plus Priority: Medium on an issue.

An issue or PR that a workflow opens with its own GITHUB_TOKEN raises no opened event, so the workflow above never runs for it. A dispatch is the one event that token can start, so such a workflow dispatches project-automation.yml with the item’s number, and may name an Area and Effort to seed on top of the defaults. extended.yml’s report failure job does this for the nightly tracking issue (#2201), and ui-security-updates.yml does it for its pull request (#2245). The same dispatch puts any item that missed the board back on it by hand:

gh workflow run project-automation.yml -f number=<n> -f area=ci -f effort=XS

Seeding is initialization, not policy. A field that already carries a value was set deliberately, and the automation leaves it alone. It reads the item’s current single-select values immediately before every write attempt, a retry included, and skips any field that is already populated, logging a ::notice:: naming the value it kept.

Until #1469 this ran unconditionally, and the consequence was not hypothetical: #1461 was created and triaged to Status: Backlog, Priority: High through gh project item-edit, the workflow completed successfully afterwards, and a later read returned Todo / Medium. A backlog proposal had become scheduled work and a high-priority defect had been demoted, with nothing red anywhere. The same sequence ran across #1462–#1468.

An unreadable item is a hard failure

The check reads the item’s current values before deciding. If that read comes back unusable — a GraphQL error arrives as HTTP 200 with a null node, which gh does not always treat as a failure — every field would look empty and the seed would write its defaults over whatever was already there. That is the original bug, reintroduced through the back door.

So the step proves the item came back before trusting the absence of a value, and fails loudly otherwise. “Could not read it” never degrades to “assume nothing is set”.

The race is narrowed, not closed — do not let anyone claim otherwise

updateProjectV2ItemFieldValue takes no expected-value or version input: the Projects v2 API has no compare-and-set. So the check above is a read-then-write and is not atomic. A field set in the window between the workflow’s read and its write is still overwritten.

What changed is the shape of the exposure, and that is worth being precise about rather than rounding to “fixed”:

  • Before: every explicit triage applied before the workflow’s write was lost, however early it was set. The window was the whole span from issue creation to the workflow finishing — seconds to minutes, and entirely the user’s to lose.
  • After: only triage landing inside the gap between one read and one write is lost — a few hundred milliseconds, and only for a field the user edits in that exact instant.

The gap stays that small when GitHub throttles the job, which happens in busy hours, the same hours triage is most likely (#1718). A seed write that fails with a rate limit or a server error is retried after a backoff of up to 15 minutes, and the field is read again before each attempt rather than once before all of them. Reading once would make the window the whole backoff: an agent triaging a batch of new issues spends the same account’s budget the workflow is waiting on, so its edits are likely to land during that wait, and the retry would write the default over them. A write whose answer was lost is covered by the same rule: the next read finds the value it already set and leaves it.

If the API ever grows a conditional update, this is the place to use it. Until then, the honest description is “a much smaller race”, and a test asserting the check is atomic would be asserting something untrue.

Triaging right after filing

Because of that window, the durable order is: create the issue, let the automation land, then set fields. Setting fields as part of creation is not wrong — it is simply the ordering with a window. Reading the board back after triage is cheap and settles it either way.

When the board add fails

The job authenticates with ADD_TO_PROJECT_PAT, and GitHub meters a personal access token against the account that owns it rather than against the token. So the workflow draws from the same 5,000-points-an-hour GraphQL budget as every agent session and script running as that account, and a busy hour can spend it before a PR is opened. The job is not in ci-ok’s needs:, so a failure blocks nothing; the item just never reaches the board, and nobody notices until a milestone audit (#1718).

Every GraphQL call in the job goes through one wrapper that tells the transient failures from the permanent ones and retries only the first kind, for at most RETRY_WINDOW seconds (15 minutes) per run:

What GitHub answeredWhat the job doesAnnotation title when it gives up
primary rate limit, budget refills inside the windowreads the reset time from GET /rate_limit (free, not metered) and sleeps until thennone; it retries after the refill
primary rate limit, budget refills after the windowfails at once and names the reset timeGitHub API budget exhausted
primary rate limit, but /rate_limit shows points leftbacks off 5s, 10s, 20s… for up to six attemptsGitHub API rate limit
secondary rate limit (too many requests in a short window)backs off 60s, 120s, 240s… until the window closesGitHub API secondary rate limit
HTTP 5xx, timeout, dropped connection, or a concurrent add of the itembacks off 5s, 10s, 20s… for up to six attemptsGitHub API unavailable
bad credentials, HTTP 401/403/404, insufficient scopes, unresolved nodefails at once, without retryingproject board token misconfigured
an empty ADD_TO_PROJECT_PATfails before calling anythingproject board token missing
anything elsefails at once, quoting the error and the two ways to recoverGitHub API request failed

The rows above describe one call. A seed write repeats the read before every attempt, so a long wait never turns into a stale write (see the race section).

Every rate-limit and outage message says “nothing is misconfigured” and ends with the two ways to recover: gh run rerun <run-id> --failed once the budget is back, or gh project item-add 1 --owner rolter-ai --url <item-url> by hand. A rerun is safe: addProjectV2ItemById returns the existing item when the content is already on the board, and the seed never overwrites a field that already has a value. The two misconfiguration titles say “this is not a rate limit” and list the scopes the token needs.

The job adds the item with a direct addProjectV2ItemById mutation rather than actions/add-to-project, because an action step can only be retried as a whole, and the add, the field read and the seed writes need the same retry policy.

That policy lives in the workflow’s run: block, which only ever runs on master when something is opened, so scripts/test-project-automation.sh extracts the block and runs it against a fake gh, a fake clock and a fake board: every row of the table, the order the patterns are tried in, triage landing during a backoff, and a write whose answer was lost. It is the board automation retry policy step of the static checks job in quality.yml and a prek hook on the workflow file. Add a case there when you add a row here.

Editing the board’s single-select options

Projects v2 option ids are not stable. updateProjectV2Field replaces a field’s option list rather than patching it: every option is minted a fresh id and the field is cleared on every existing item in the project.

project-automation.yml no longer pins any of those ids — it resolves both the field and the option by name on every run and fails with an ::error:: naming what it could not find (#1096). So an edit that keeps the option names needs nothing from you here; only a rename does, and it announces itself the next time an issue is opened rather than silently dropping items into the untracked “no status” column.

Before touching the options:

  1. snapshot the current values —

    gh api graphql --paginate -f query='
      query($endCursor: String) { organization(login:"rolter-ai"){ projectV2(number:1){
        items(first:100, after:$endCursor){ pageInfo{hasNextPage endCursor}
          nodes{ id fieldValues(first:20){ nodes{
            ... on ProjectV2ItemFieldSingleSelectValue { name field {
              ... on ProjectV2SingleSelectField { name } } } } } } } } }}'
    
  2. pass the complete option list to updateProjectV2Field, including the ones you are keeping

  3. restore the snapshot with updateProjectV2ItemFieldValue, in batches of about five mutations per request — larger batches hit Resource limits for this query exceeded

  4. if you renamed an option, update the name in project-automation.yml (set_field Status Todo, set_field Priority Medium) in the same change; ids need no attention

Merge protection on master

Two green pull requests can still merge into a red master. It happened at c8b6d0dc: #1132 added a field to ProviderConfig, #1138 added a test that built a ProviderConfig with a full struct literal, neither branch contained the other’s change, and the break only existed on the tree that had both. The first person to see it was the author of an unrelated UI-only PR, whose ci-ok went red for a reason nothing in their diff explained (#1150).

This page records what the repository does about that class of failure, and why.

The two halves of the problem

A semantic conflict is invisible to a per-branch gate. ci-ok runs against each PR’s head, not against the tree that merging it would produce. GitHub will merge a branch that is behind master without re-running anything, so any pair of changes that only conflict semantically — a new field, a renamed function, a widened enum — passes both gates and fails on the merge result.

ProviderConfig made the blast radius maximal. With 16 fields and no Default, every fixture in the workspace spelled the struct out in full, so adding one field was a compile error in every test that built one. That turns a narrow race into a workspace-wide break.

What we changed

ProviderConfig and ProviderKind now derive Default (crates/rolter-core/src/config.rs), and every test and bench fixture is written as the two or three fields it cares about plus ..Default::default(). Adding a field to ProviderConfig is now a no-op for fixtures.

Production code is deliberately excluded. PostgresStore’s row mapping and the config parser still write the literal out in full, because there a new field is a question that someone owes an answer to — silently defaulting a column the store forgot to read is exactly the bug the exhaustive literal prevents. The Default exists for fixtures; the compiler still argues with you on the paths that map real data.

That removes the amplifier. It does not remove the race: two PRs can still conflict semantically in any other type.

The decision on merge order

The repository merges master through GitHub’s merge queue, and keeps required_status_checks.strict = false (branches need not be up to date).

The repository side of this — the merge_group: trigger and everything that hangs off it — ships with #1318. Switching the queue on is a branch-protection setting that no pull request can make; until someone with admin runs the commands under The settings, for whoever has admin below, merging behaves exactly as it did before and the merge_group trigger is inert. Nothing breaks in the meantime.

This page used to record the opposite decision, with an explicit condition for revisiting it: if a semantic conflict reaches master twice more, turn the merge queue on. The condition was met. c8b6d0dc above is the first; the second is #1318 — #1310 added openapi::tests::every_registered_route_is_documented while #1311 and #1299 each mounted a new route, all three green, all three blind to each other, and the combined tree failed the completeness assertion. The reasoning behind the switch is ADR-0033.

Three options were on the table:

OptionWhat it buysWhat it costs
strict = trueThe gate always ran on a tree containing current masterEvery PR must be manually rebased and re-gated whenever anything else merges; with several agent worktrees and dependabot in flight, the last-merge-wins churn is continuous and can outrun a gate run
Merge queueGitHub re-runs ci-ok against the prospective merge result, batching and ordering merges without anyone pushing rebasesOne extra gate run per batch, and merging becomes asynchronous
NeitherNo new frictionSemantic conflicts still reach master

How merging works now

  • You still open and review PRs exactly as before. ci-ok on the PR head is still required, and nothing enters the queue without it.
  • gh pr merge enqueues rather than merges. The PR shows as queued and lands when its merge-group run goes green, typically one gate run later. Do not report a PR merged until it actually is — check with gh pr view <n> --json state,mergedAt.
  • A red merge-group run dequeues the PR, leaving master untouched and the PR open with a comment naming the failure. Fix it on the branch and requeue; there is nothing to clean up on master.
  • Stacked PRs are unaffected. A stacked child targets its parent’s branch, which is not protected and has no queue, so it merges as it always did — with PUT .../pulls/{n}/merge-async, which is a property of the PR being in a stack and not of the base branch. Only the bottom PR of a stack targets master, and it queues like anything else. See the stacked-PR rules in AGENTS.md.
  • ci.yml still runs on every push to master. That backstop stays: it is the run that sees the tree everyone will branch from, and it is what catches anything that reached master outside the queue.

The settings, for whoever has admin

Enabling the queue is a repository setting, so no pull request can make it. The workflow side (merge_group: in .github/workflows/ci.yml) is inert until it is turned on.

# enable the queue on master, asking for the same single required check
gh api -X PUT repos/rolter-ai/rolter/branches/master/protection/required_status_checks \
  -F strict=false \
  -f 'checks[][context]=ci-ok'

# the queue itself has no REST endpoint: Settings -> Branches -> master ->
# "Require merge queue", then
#   merge method                     squash
#   build concurrency                5
#   minimum group size               1
#   maximum group size               5
#   wait time to meet minimum        5 minutes
#   only merge non-failing pull requests   on
#   status check timeout             90 minutes

The numbers above are the starting point, not a law. maximum group size is the cost knob: it is how many PRs share one gate run, so raising it cuts CI spend and raises how much has to be re-tested when one entry in a batch fails. The timeout must comfortably exceed a full quality + codeql run.

The habit that still matters

The queue does not retire the cheap half of the same discipline: watch the master push run after your PR merges. It is the run that sees the tree everyone else will branch from, and it is the only thing watching a merge that reached master outside the queue — an admin bypass, or the queue being off. That run is guaranteed to exist for every merge commit; see the ci-ok gate and the title-edit fast path, which also covers why a PR title edit cannot report green over a gate run that has not finished, and why a merge-group ref cannot take that fast path at all. Related: #1158 tracks whether enforce_admins belongs on master.

When you add a field to a widely-constructed type

  • Give the type a Default if a meaningless-but-valid value exists for it, and say in the doc comment which paths are still expected to write the literal out in full.
  • If a neutral default would be a lie — an enum where every variant means something specific, a struct where an empty value is a security-relevant choice — do not derive Default. Add a #[cfg(test)] builder instead, so production code keeps the exhaustive-match property.

The ci-ok gate and the title-edit fast path

ci-ok is the single required status check on master. Everything else is aggregated into it, so adding or removing a job never means touching branch protection settings. The two heavy jobs, quality and codeql, reach it through needs; the cheap pull-request metadata checks (pr-title and the two agent-session-url checks) run as steps inside it (see What runs inside ci-ok). This page records the one place that aggregation is subtle: the fast path for a pull-request title edit, and the rule that keeps it honest.

What runs inside ci-ok

ci-ok needs [quality, codeql] and runs on every event (if: always()). Its steps, in order:

StepRuns on
checkoutpull_request, workflow_dispatch, merge_group
assert the gate already ran for this commitpull_request with action edited (the fast path)
pr-titlepull_request
no agent session urls (pr body)pull_request, workflow_dispatch, merge_group
no agent session urls (commits, dispatch/queue)workflow_dispatch, merge_group
assert every required check succeeded (verdict)every event, under always()

The checkout takes fetch-depth: 1 on a pull request, where only the script is read, and full history on a dispatch or queue run, where the commit-range step walks the range locally. The expression is github.event_name == 'pull_request' && 1 || 0. That form is safe because its middle operand, 1, is truthy. The reverse, cond && 0 || 1, would always yield 1, since 0 is falsy.

Every check step is guarded by !cancelled() && <its event guard>, never by always(). One failing check does not hide the ones after it, and a run that the concurrency group cancelled spends nothing on checks nobody will read. Only the verdict step runs under always(). In a cancelled run every other step skips, and a verdict that skipped with them would leave the job, which is the one required check, green over nothing.

The verdict reads every job result and step outcome through env: and reports every broken rule before it exits, not just the first one:

ResultMust be
quality, codeqlsuccess; on an edited run, success or skipped
assert the gate already ransuccess on an edited run
pr-titlenever failure; success on any pull_request run
no agent session urls (pr body)success on every event except push, where it is skipped
no agent session urls (commits, dispatch/queue)never failure; success on workflow_dispatch and merge_group
pr-title skipped on a workflow_dispatch runallowed, with a ::warning:: that the title went unvalidated (see below)

These three checks used to be jobs of their own, pr-title, session-urls and dispatch-commit-urls, each holding a runner for a few seconds of work and often queueing minutes to get one (#2025). As steps they cost no extra runner, and a title or body edit starts one job instead of three. The price is when title feedback arrives. On a run a commit started (opened, synchronize, reopened), pr-title now runs only after quality and codeql finish, about eight minutes in, rather than within seconds. An edited run skips both, so a title fix made after the gate is still checked within seconds. The action fetches the title live rather than reading it from the payload, so a title fixed while the gate is still running is the one that step validates. The body step does the same on a pull request (see The pull_request path reads the live body).

Why the fast path exists

ci.yml triggers on pull_request: [opened, synchronize, reopened, edited]. The edited type is there for a single reason: PR titles are validated as Conventional Commits by ci-ok’s pr-title step, so fixing a bad title has to be able to turn ci-ok green without a new commit. Without an edited trigger, the only way to re-run the title check would be to push an empty commit, which invalidates every review and re-runs a twenty-minute gate for a typo.

The heavy jobs are skipped on that event (if: github.event.action != 'edited' on quality and codeql): a title lives in GitHub’s database, not in the tree, so no test result can change because of it. The tree that was gated is the same tree.

Why that was a hole

Every run of ci.yml writes a check-run named ci-ok against the pull request’s head sha. Branch protection, and anything reading check-runs?check_name=ci-ok, resolves the newest check-run with that name.

An edited run finishes in under a minute, because it skips the gate and runs only the metadata checks. So retitling a PR while the real gate run on the same sha is still in progress produced a newer, successful ci-ok on that sha, and the commit became mergeable before a single test had run. That is not theoretical: on 2026-09-08, #1315 (head 67586128) and #1267 (head cae26a31) were each rebased, pushed and retitled inside a minute, ended up with two ci runs per sha — one edited run green at 17:50:08Z, one real pull_request run still in_progress — and #1315 was squash-merged on the hollow green. The gate went green afterwards, so master survived on luck (#1328).

An earlier round of this (#767) had already made the edited run harmless in one direction, by giving it a per-run concurrency group so it cannot cancel the gate run it is racing. What was left was the other direction: it could still outrank it.

The rule

The edited fast path may report green only when the gate has demonstrably already finished, successfully, on this exact head sha. ci-ok asserts that before it accepts a skipped quality, by listing the other runs of ci.yml on the same head sha (.github/workflows/ci.yml, the assert the gate already ran for this commit step):

  • Any other ci.yml run on this sha that is not completed — queued, in_progress, waiting — fails the step. The commit is not gated yet, and the run that is still going will write its own, newer ci-ok when it finishes, so nothing is lost by refusing here.
  • No completed run on this sha whose gate-ok job succeeded fails the step too. This closes the same hole in its other shape: a retitle over a gate run that failed also used to write a newer green ci-ok.
  • A cancelled run does not count as a pass. It is completed, so it does not block as in-flight, but it carries no verdict — it is treated exactly like a missing run, which is to say the fast path stays red until a real gate run succeeds.
  • A failed API query is never green. Both the run listing and each run’s job listing fail the step when they cannot be read. No answer is not an answer.

gate-ok: what the guard actually asks

The question the fast path needs answered is narrow — did the heavy gate run on this sha, and did it pass — and for a long time it was asked in a way that answered something broader: is there a run on this sha whose overall conclusion is success.

Those differ whenever a run fails on something that is not the gate. A run can pass quality and codeql and still end failure because the PR-body check rejected the body. Under the old question that run counted as no gate at all, and since the opened run is the only one that runs the gate and edited runs never re-run it, nothing could ever make that sha green again — the failure was permanent and unfixable from the PR side (#1522).

So ci.yml has a gate-ok job that records the gate’s verdict by itself:

gate-ok:
  if: github.event.action != 'edited'
  needs: [quality, codeql]
  steps:
    - run: echo "quality and codeql both succeeded on this run"

It is deliberately trivial, and two things about it are load-bearing:

  • No if: always(). With the default condition the job runs only when every job it needs succeeded, so a failed gate leaves gate-ok skipped rather than adding a second red check beside ci-ok. skipped is not success, so the guard reads it correctly either way.
  • It never re-derives quality’s verdict. needs.quality.result is computed by Actions, which already accounts for what continue-on-error covers inside quality.yml (the coverage job, the semver-checks steps of rust build) and for coverage being skipped on every event but pull_request. Any hand-rolled scan of job conclusions would get those wrong.

The guard then walks the completed ci.yml runs on the sha and asks each one whether it has a gate-ok job that concluded success. A cancelled run has no such job, so it still counts as no run at all — #1328 stays closed.

Details that matter if you touch this code:

  • The listing is scoped to ci.yml by filename. A run of docs.yml or ui-e2e.yml on the same sha says nothing about this gate.
  • It is keyed on github.event.pull_request.head.sha, not github.sha. On a pull_request run github.sha is the throwaway merge commit; check-runs and workflow runs hang off the PR head.
  • The current run appears in its own listing and is excluded by run id (github.run_id), never by name — every run of this workflow shares a name.
  • The job carries actions: read for the listing, and the query writes to a file that jq then reads. The step runs under set -o pipefail, where piping into head or grep -q fails the step for the wrong reason when the reader short-circuits; this repo has fixed that bug twice already (#1291, #1306).

ci-ok remains the single required status check. No branch-protection change is needed for any of this.

What you see when it fires

ci-ok goes red on the edited run with gate still running on <sha>, preceded by a ::notice:: spelling out that nothing is wrong with the commit. The verdict step then adds the gate is not proven green on this head sha, which points back at that step and adds nothing new. This is expected and self-healing: when the gate run finishes it writes its own ci-ok on the same sha, which is newer and wins.

Editing a PR body during a long gate run is an ordinary two-step — write the body, then add the follow-up issue numbers once those issues exist — so this red is common. The two branches of the guard are worded to be told apart at a glance, because they mean opposite things:

MessageMeansAction
gate still running on <sha>the gate is fine and unfinishednone; the in-flight run supersedes this
no completed ci run on <sha> recorded a passing gate-ok jobthe gate failed, was cancelled, or never ranpush a fix or re-run the gate

The one case that needs a human is the narrow race where the gate run completes between the listing and the assertion — then the red edited ci-ok is the newest one on a fully gated commit. Re-run the ci-ok job (or edit the title again) and it goes green. Do not merge with an admin bypass instead; the point of the check is that the newest ci-ok is trustworthy.

Why the unfinished case is red rather than grey (#1511)

“The gate has not finished” is not a failure, and GitHub has a conclusion for it — neutral, which renders grey and does not satisfy a required status check. Reporting it that way was proposed and rejected. The reasons are recorded here so it is not re-litigated:

  • A GitHub Actions job cannot conclude neutral. A job ends success, failure, cancelled or skipped; nothing a step does changes that. neutral is reachable only for a check-run posted through the Checks API, which ci-ok is not — it is a job, and its check-run is written by Actions.
  • Posting a second ci-ok check-run through the API would be worse. The job’s own check-run is still written, so the two race, and branch protection reads whichever lands last. The guard exists precisely because the newest ci-ok on a sha must be trustworthy (#1328).
  • Self-cancelling the run to reach cancelled (grey) costs more than it buys. It needs actions: write on ci-ok — the one required status check — purely for a colour, and ci-ok runs if: always(), so a run whose PR-body check genuinely failed would go grey and hide a real finding unless the cancel were conditioned on every other check’s result first.

So the colour stays. What changed instead is the message: the unfinished case now names itself and says no action is needed, rather than reading like the commit is broken. If this becomes painful again, the fix worth considering is not the conclusion but the ordering — the red is only a problem while it is the newest ci-ok on the sha.

The merge queue

master merges through a merge queue (ADR-0033), so ci.yml also triggers on merge_group. (The trigger is inert until the queue is switched on in branch protection — see merge protection on master.) That run checks out a synthetic ref — refs/heads/gh-readonly-queue/master/pr-<n>-<sha> — holding master plus every entry ahead of this one in the queue, and reports the same ci-ok against it. It is the only run that ever sees the tree that will actually exist, which is the whole point: ci-ok on a PR head says nothing about a semantic conflict with something that landed after the branch was cut (#1318).

ci-ok stays the single required status check. The queue asks the repository for its required checks by name, so there is no second name to configure and no second gate to keep in sync.

A merge-group ref must never take the fast path

This is the one rule where the queue and the title-edit fast path meet, and it runs the wrong way by default if nobody thinks about it. The fast path reports green without running the gate, on the strength of an earlier run against the same head sha. A merge-group tree has no earlier run — it was assembled seconds ago and nothing has ever built it — so “the gate already passed here” is not a claim that can be true. A merge-group run that took the fast path would report green having tested nothing, inside the mechanism that exists to stop exactly that.

Today merge_group carries action: checks_requested, so a guard written as github.event.action != 'edited' already lets the gate run. That is an accident of GitHub’s event vocabulary, not a rule. So every guard around the fast path is scoped to the event as well as the action:

guard
quality, codeql, gate-okgithub.event_name != 'pull_request' || github.event.action != 'edited'
ci-ok’s assert the gate already ran step!cancelled() && github.event_name == 'pull_request' && github.event.action == 'edited'
ci-ok’s verdict branch for the skipped gate"${EVENT_NAME}" = "pull_request" and "${EVENT_ACTION}" = "edited"

These are equivalent to the old conditions on every event that exists now. The change is that they cannot stop being equivalent when GitHub adds an event or reuses an action name.

What runs, and what is allowed to skip

Job or stepOn merge_groupWhy
quality, codeql, gate-okrunthe point of the run
ci-ok step no agent session urls (pr body)runs, and must succeeda squash merge writes the body into the commit message, and the queue is what performs the merge
ci-ok step no agent session urls (commits, dispatch/queue)runs, and must succeedthe only thing that reads the commit messages of PRs batched ahead of this one
ci-ok step pr-titleskippedthe payload has no title, and nothing enters the queue without a green ci-ok on the PR, where pr-title did run
static checks step gitleaks (branch history)runs, over base_sha..head_shascans the commits the queue is about to write to master, entries batched ahead of this one included
quality job coverage (informational)skippedinformational and pull_request only; extended.yml runs the same job on master nightly

The two session-url steps resolve their subject differently here, because a queue ref belongs to no pull request head and --pr-for-ref cannot match it:

  • the body is fetched with --pr-for-queue-ref, which parses the PR number out of gh-readonly-queue/<base>/pr-<n>-<sha> and looks it up in the open-PR listing. A queued PR is open until the queue merges it, so not finding it is a hard failure rather than a pass — the same no answer is not an answer rule the rest of this file follows. Like the other modes it honours ROLTER_PULLS_JSON, so it is runnable against a fixture:

    ROLTER_PULLS_JSON=pulls.json bash scripts/check-agent-session-urls.sh \
      --pr-for-queue-ref rolter-ai/rolter gh-readonly-queue/master/pr-1318-deadbeef
    
  • the commits need no lookup at all: the merge_group payload names both endpoints, so the range is base_sha..head_sha through the plain --commit-range mode.

Both of those steps live in ci.yml’s ci-ok job rather than in quality.yml, because resolving a pull request needs a token and quality.yml deliberately takes none (#734).

Two places inside quality.yml did need adjusting, both steps of the static checks job today. migrations append-only diffs against origin/master, and a merge-queue checkout is a synthetic ref with no origin/master fetched — the script’s no such ref; skipping branch would have turned the gate into a silent no-op on exactly the runs that matter. It now takes its base from github.event.merge_group.base_sha, which the payload provides and which is an ancestor of the queue head, falling back to origin/master everywhere else. A called workflow sees the original event, so the expression resolves inside quality.yml without ci.yml having to pass anything in, and no secret is involved.

The other is the gitleaks (branch history) step. On a pull request it reads pull_request.base.sha..head.sha, and a merge_group payload has no pull_request object, so until #1722 the step skipped itself and only the working-tree pass ran. The working-tree pass is the half that matters for the merge gate, since a secret that survives into the merged tree is caught either way. The history pass is the only one that reads commits, though, and on a queue run those are the commits master is about to receive: this entry’s plus every entry batched ahead of it, which no single per-PR run ever scanned as one range. It now takes merge_group.base_sha..merge_group.head_sha from the payload, the same way ci-ok’s commit-range step does, so it needs no API call and no token. base_sha is an ancestor of the queue head, and the static checks job checks out with fetch-depth: 0, so the commit is in the clone.

Concurrency

merge_group runs get a per-run concurrency group, alongside push and edited. A cancelled run is not a passing required check, so cancelling a merge-group run dequeues the PR it was testing and everything batched behind it. GitHub does give each queue entry its own ref, so github.ref alone would usually be unique — but the queue re-forms that ref when an entry ahead of it fails, and the replacement must not shoot down a run that is still reporting. The per-run group makes that impossible rather than unlikely.

Agent session urls

scripts/check-agent-session-urls.sh rejects a coding-agent session or remote-connection url — a claude.ai/code/session… link, or any agent’s own <Name>-Session: git trailer carrying a url — in a commit message or a pull request body. It runs as the no-agent-session-urls prek hook, as the no agent session urls (commits) step of quality.yml’s static checks job over every commit the PR introduces, and as two steps of ci-ok in ci.yml: no agent session urls (pr body) over the PR body, read live from the API, and no agent session urls (commits, dispatch/queue) over the commit range on a dispatched or merge-queue run, where the quality.yml step cannot see one. The links are ephemeral and sometimes private, and a merged commit message can only be corrected with a history rewrite, so the rule has no exceptions.

The part that surprises people is that the offending line is often not one the author wrote. PR-authoring tooling for a coding agent may append a session-scoped footer to the body after the author’s own content — a _Generated by [Claude Code](https://claude.ai/code/session_…)_ line, or the equivalent <Name>-Session: trailer on a commit — so the check fires on text that first became visible when it failed. It is still a real hit: the url is in the body, and the body is what everyone reads.

The convention, for a human or an agent opening a PR:

  1. After creating the PR, read the body back and confirm it carries no session url. Do not assume the body you submitted is the body that was stored.
  2. If the tooling injected one, strip just that line with a direct PATCH /repos/{owner}/{repo}/pulls/{n} (for example gh api -X PATCH repos/rolter-ai/rolter/pulls/<n> -f body=@body.md), not another pass through the authoring tool. The footer is appended on the creation path only, so a direct PATCH does not re-trigger it and the edit sticks. Strip it as soon as you see it: ci-ok reads the live body after the gate, so the run that opened the PR goes green on its own once the line is gone. See Stripping an injected footer below for the red edited run you may see first.
  3. For a commit message, the equivalent is an amend or rebase that drops the trailer before the push — quality.yml re-checks every commit in the range, so a --no-verify push does not get through.

This is a workaround for tooling behaviour outside this repository’s control, not a relaxation of the rule. The detection stays unconditional: never add a regex exception, an allowlist or a special case for “Generated by Claude Code” or any other footer text to the script’s PATTERN. The value of the check is that it has no exceptions — a carve-out keyed on “the footer” is one an agent can talk its way into, and it would let a genuinely private session link ride into history inside a line that merely looks generated.

The workflow_dispatch path checks the body too

ci.yml can be triggered manually, and that trigger is not decoration: the release PR is opened by release-plz with the repo GITHUB_TOKEN, GitHub suppresses downstream events for token-created refs, and so neither push nor pull_request ever fires for it (#1025). ci.yml is dispatched against the release branch to gate it once a maintainer labels the PR release:ready: by release-pr-ready.yml when the label is added, and by release-plz.yml on each later push while it stays (see cutting a release).

The trap is that a dispatched run skips everything guarded by github.event_name == 'pull_request', which once included both the PR-body check and pr-title, while quality and codeql still run, because their guard is on action, which is empty on a dispatch. ci-ok used to accept both skips, so a dispatched run reported green having never looked at the PR body. That made dispatching the cheapest way to clear a red PR, and it is how #1519 and #1521 actually went green (#1523).

So the no agent session urls (pr body) step runs on workflow_dispatch as well. With no pull request in the event payload it resolves one from the API by head ref:

  • an open PR has this ref as its head → its body is checked, exactly as on a pull_request run. This includes the release PR, which is the whole reason the trigger exists.
  • no open PR has this ref → nothing to check, and the step says so with a ::notice:: and succeeds.
  • the API listing still fails after three attempts → the step fails. A flaking query must never resolve to green; the same rule ci-ok’s own run listing follows.

That resolution lives in scripts/check-agent-session-urls.sh --pr-for-ref rather than inline in the workflow, so it can be exercised without a CI run: point ROLTER_PULLS_JSON at a file shaped like the API response and no network call is made.

ROLTER_PULLS_JSON=pulls.json \
  bash scripts/check-agent-session-urls.sh --pr-for-ref rolter-ai/rolter some/branch

Workflow-embedded shell is shell nobody can run, and this logic decides whether a gate reports green.

ci-ok’s verdict was tightened to match: a skipped PR-body check is accepted only on a push build, the one case with no pull request to check. Anywhere else, a skip is a failure rather than a pass.

The commit half of the dispatch path

The body was only one half. quality.yml’s session-urls job — the one that reads the commit messages the PR introduces, today the no agent session urls (commits) step of static checks — took base and head from github.event.pull_request, so a dispatched run skipped it. A skipped job inside a reusable workflow does not fail it, so quality still reported success and ci-ok went green having never read a commit message (#1562).

That is the wrong place to have no verdict twice over: the release PR only ever gets a dispatched run, and before gate-ok a dispatch was how a head sha whose opened run failed its body check got unstuck (#1522). A commit message is also the half that cannot be fixed after the fact — correcting a merged one takes a history rewrite.

ci-ok therefore runs a no agent session urls (commits, dispatch/queue) step on workflow_dispatch and merge_group. On a dispatch it resolves the range through --commit-range-for-ref; on a queue run the payload names both ends (see the merge queue). It lives in ci.yml rather than beside its pull_request twin because quality.yml takes no secrets (#734): every job there scans the checked-out source with pinned public tooling, so the gate behaves identically on dependabot and fork PRs, which receive none. Resolving a pull request needs a token, so it belongs on this side of the line. The pull_request path in quality.yml is unchanged.

Since #2025 that path is a step inside static checks rather than a job, and a skipped step does not fail a job any more than a skipped job fails a workflow. The job’s report step closes that gap for every step it holds: it knows which events each step must run on and fails the job when one of them was skipped there (see the static checks job).

The contract matches the body half: no open PR for the ref is a ::notice:: and a pass, a failed API listing fails the step, and a range whose endpoints are not both in the clone fails rather than silently checking nothing. The ci-ok checkout takes full history on these two events for that reason. The verdict treats a failure here as fatal on any event, and additionally requires success on a dispatched or queued build — a skip is honest only where the step does not apply.

Both --pr-for-ref and --commit-range-for-ref share one lookup helper and one fixture override, so neither is shell that only CI can run:

ROLTER_PULLS_JSON=pulls.json \
  bash scripts/check-agent-session-urls.sh --commit-range-for-ref rolter-ai/rolter some/branch

pr-title still cannot run on a dispatch — the action it uses takes the pull request out of the event payload, and there is no supported way to hand it one. The remaining gap is therefore narrow: two PRs take the dispatch path, and a workflow writes both titles. release-plz titles the release PR, and ui-security-updates.yml gives its PR a fixed title that ui/scripts/security-updates.test.ts checks (see UI security updates). ci-ok emits a ::warning:: naming that the title went unvalidated rather than letting a silent skip imply otherwise.

The pull_request path reads the live body

On a pull_request run the no agent session urls (pr body) step reads the body from the API (GET /repos/{owner}/{repo}/pulls/{n}) through scripts/check-agent-session-urls.sh --pr-number. It does not read ${{ github.event.pull_request.body }}. The PR number is the only field it takes from the payload, and that never changes.

The payload’s copy of the body is frozen when the event fires, and the step runs only after quality and codeql, minutes later. Reading that copy left a hole (#2035). Say a session URL lands in the body while a synchronize run is still running the gate:

  1. The edit starts an edited run. Its body step fails on the new body, and its fast path declines anyway because the gate has not finished. That ci-ok is red.
  2. The synchronize run finishes the gate. Its body step checks the payload, which predates the edit, and passes. That ci-ok is green.
  3. The green one is the newer ci-ok check-run on the head sha, so it is the one branch protection reads. The PR is mergeable with the URL in its body.

The repository’s squash message is currently the commit list (squash_merge_commit_message: COMMIT_MESSAGES), so a merge today does not copy the body onto master. What leaks is the PR body itself, and the rule covers the body as firmly as a commit message. #1327 is the mirror image of this: there an edited run reported green before the gate had run, here a gate run reported green over an edit it never saw.

A live read closes it. The gate run’s body step now reads the body after the gate, so it sees any edit made while the gate ran. An edit made after that starts an edited run, and that run’s newer ci-ok reads the edited body.

Two objections kept the payload until #2025, and neither holds any more:

  • The read would race the strip. While the check was a job of its own it ran seconds after the PR was created, so a live read would usually have seen an injected footer before anyone could remove it. As a step of ci-ok it runs after the gate, and a strip made in the first minutes is already in place.
  • A fork’s read-only token might not be allowed to read the PR. It is. pr-title already fetches the same PR with the same token on every pull_request run (pulls.get in amannn/action-semantic-pull-request, which re-reads the PR because the payload can be stale), and ci-ok holds pull-requests: read. Dependabot PRs, whose token is read-only just as a fork’s is, pass that step today.

The read gets three attempts, five seconds apart, and then fails the step. A response that is not the requested PR, such as another number or something that is not JSON, fails it too. Every lookup in the script goes through the same helper, so the dispatch and merge-queue listings follow the same rule.

scripts/test-agent-session-urls.sh covers this. It extracts the step’s run: block from ci.yml and runs it against a fake gh and a fake sleep. A clean payload body with a dirty live body must fail, a flaking API must be retried, an API that stays down must fail, and the dispatch and merge-queue branches resolve their PR through the same fake. It also fails if the step’s env: names github.event.pull_request.body again. It runs as the pr body lookups against a fake gh step of the static checks job in quality.yml and as the agent-session-urls prek hook:

bash scripts/test-agent-session-urls.sh

Since the live read, the quickest way to green is to strip the footer as soon as you see it:

  1. Read the PR body back after creating the PR.
  2. If a session URL is there, strip that line with a direct PATCH /repos/{owner}/{repo}/pulls/{n}. There is nothing to wait for.
  3. The edited run the strip starts usually ends red with gate still running, since it cannot vouch for a gate that has not finished. Leave it. When the opening run’s gate finishes, its body step reads the stripped body, and its ci-ok, the newer check-run, goes green.

If the strip lands after the opening run’s body step has already read the dirty body, that run ends failure. Its gate still recorded a passing gate-ok, though, and the edited run the strip starts finds it and goes green. No new commit, no dispatch.

That second case used to strand the head sha. The opened run was the only one that ran the heavy gate, and the edited fast path found no successful run to point at. The only ways out were a new commit or a manually dispatched run, neither of them documented, and the latter only working by accident (#1522). gate-ok closed that: the fast path looks for a passing gate, not a passing run.

Before the live read, the order was the other way round. The body step read the payload frozen at opened, so a footer present at that moment failed the opening run whatever happened next, and the advice was to wait for that run to finish before stripping. Stripping early made the edited run decline on the unfinished gate branch and cost a second edit. Seen on #1565, the first PR opened after gate-ok landed (#1566):

RunEventStartedOutcome
35249017068opened16:50:04session-urls, then a job of its own, failed on the frozen body; gate-ok succeeded
35249064201edited16:50:35ci-ok red — gate still running, the strip was too early
35249935368edited16:59:31ci-ok green off the same gate-ok, no new commit

Today the first run’s body step would run after its gate, well after the 16:50:35 strip, and that run would have gone green by itself.

Which red you are looking at is written in the message, and the two mean opposite things:

ci-ok saysMeansDo
gate still running on <sha>a title or body edit landed before the gate finishednothing; the gate run writes a newer ci-ok when it finishes, after reading the title and body live
no completed ci run on <sha> recorded a passing gate-ok jobthe gate actually failed, was cancelled, or never ranfix the commit; no amount of editing helps

One timing still costs a second edit: a strip that lands in the few seconds between the gate run’s body read and the end of its ci-ok. The gate run fails on the body it read, and the edited run declines because the gate run is still in progress. One more edit after the gate run finishes goes green off its gate-ok.

Why not a separate pr-title workflow

The obvious alternative is to drop the edited trigger from ci.yml and give pr-title its own tiny workflow with its own check name, so ci-ok is only ever produced by a full gate run. That removes the possibility of a hollow green rather than detecting it, and it needs no token and no API call.

It was rejected on one point: pr-title would stop being aggregated into ci-ok, so branch protection would have to require two checks, and that is a repository-settings change no pull request can make. Until a human made it, PR titles would be unenforced — a silent regression traded for a loud one. The ci-ok-is-the-only-required-check invariant is load-bearing here, and the API query keeps it. Since #2025, pr-title is a step inside ci-ok rather than a job it needs, which holds the same invariant with no second check to aggregate at all.

Every merge commit keeps its run

The same incident showed a second, smaller gap. ci.yml’s concurrency group used to be shared by every push to master, so a merge landing a minute after another cancelled the earlier one’s run: merge commit 7fd73db6 was cancelled by c6a96378 and master had no completed ci run for it.

Push runs now get a per-run concurrency group, so a master run is never cancelled and never queued behind another. cancel-in-progress: false would not have fixed this — it queues the newer run instead of cancelling the older one, and a third push cancels the pending one, so the middle commit still ends up ungated.

This matters because of the working rule in merge protection on master: the push run is the one that sees the tree everyone else will branch from, and it is how a semantic conflict between two independently-green PRs gets caught. A merge commit with no completed run is a blind spot in exactly the place that rule watches. The cost is more concurrent runs on a busy merge day, which is the cheaper side of the trade.

Pull-request runs are unchanged: pushing again to a PR still cancels the in-flight run for the superseded commit, which is what you want, because nobody will ever merge that sha.

codeql (rust) on a pull request with no Rust change

codeql (rust) is the longest job ci-ok waits on (about 8 min), and about half of all pull requests touch no Rust at all. On pull_request runs its first step, detect rust changes, diffs the merge commit against its first parent (the base) and looks for a *.rs file, a Cargo.toml, Cargo.lock, rust-toolchain.toml or anything under .github/codeql/. With none of those, the leg skips the toolchain setup, cargo fetch, initialize codeql and analyze, writes a notice saying so, and ends success. The other leg, codeql (actions-js-python), always runs.

This is the one diff-based skip in the gate (ADR-0034), and it cannot change a verdict: the codeql job never fails on alerts, so a skipped analysis and a clean one both leave codeql at success. The limits that keep it safe:

  • only pull_request skips. push, merge_group and workflow_dispatch always analyse, so the merge queue runs the full Rust analysis on the tree that is about to land, and every master commit (and so every release tag) keeps a complete one. That queue run is also what catches a pull request that breaks the leg itself, since a change to ci.yml alone does not count as a Rust change;
  • the step fails open. A HEAD that is not a merge commit, a failed diff or a failed grep all mean analyse, and the step is continue-on-error, so an error in it leaves the output unset, which every later guard reads as run.

A skipped PR has no /language:rust analysis of its own, so GitHub’s code scanning summary on it may say a configuration present on master was not found. That is expected and blocks nothing.

API stability and the semver gate

quality / semver-checks was red on every run, including on master and on pull requests that touched no Rust at all (#1213). The failure was real but it said nothing anyone could act on:

--- failure struct_pub_field_missing: pub struct field removed or renamed ---
  field allow_direct_provider_keys of struct SecuritySettings, previously in
  rolter-store-0.1.0/src/postgres/models.rs:726

A check that is red on a clean tree is worse than no check. Everybody learns to scroll past it, and the one time it catches something real it is indistinguishable from the noise.

Why it was always red

Two causes stacked.

The baseline was the published crate. cargo-semver-checks diffed the working tree against rolter-store-0.1.0 on crates.io, so every pre-1.0 change to a public item in a published crate was reported.

The version on master is the released version. release-plz bumps versions at release time, in the release PR — so between two releases the tree always claims to be the version it is being compared against. cargo-semver-checks reads that as “no change; assume minor”, and under 0.x semantics a minor bump does not license a breaking change. Any breaking change is therefore red from the moment it merges until the next release, no matter how deliberate it was.

That is not a bug in the tool. It is what happens when a gate is pointed at a surface that has not been declared stable yet.

What the check covers now

The gate is scoped instead of silenced. It compares each change against the commit it lands on, not against the published crate, and it checks only the crates listed in GUARDED, the env of the cargo semver-checks (guarded crates) step of the rust build job in .github/workflows/quality.yml:

CrateGuardedWhy
rolter-authyesvirtual keys, roles and access checks; consumed by both planes
rolter-balanceryesin-tree strategies all implement one LoadBalancer trait, so a change to it reaches every strategy at once
rolter-gatewayyesdata-plane surface; changes here are behavioural, not structural
rolter-proxyyesprovider dialect adapters
rolter-corenoconfig types churn with every new provider kind and strategy
rolter-storenorepo signatures and row structs track the schema
rolter-controlnoCRUD payload structs track the dashboard
rolternolauncher binary, no library surface

The guarded crates are checked at --release-type patch — the strict reading. On those crates a change to a public item is surfaced rather than passing unremarked: the check goes red, and the pull request either keeps the API or says in its description that moving it was the point.

The unguarded three are unguarded because their public items exist purely so the two binaries can share code, and they change shape whenever the schema, the config or the dashboard payloads change. Checking them produced the permanent red this page opens with.

The list is about review value, not about a promise. No rolter crate has a stable Rust API — ADR-0032 decides that explicitly, and 1.0 does not change it. A crate is on the list because an unintended change to its API is likely enough to be an unintended change in behaviour that a second look is worth the noise, and off it when it is not. Adding or removing one is that judgement, made in the same pull request, with a line in the table saying why.

Why the baseline is the change’s own base

Until #2204 the baseline was the previous release tag. That removed the first cause above and kept the second: a deliberate break stayed red on every later run until the next release. From #1863 on, every pull request failed over two RouteContext fields that none of them had touched (#1969), so a red run once again said nothing about the change it was reported on.

The resolve baseline step now picks the commit the change lands on:

RunBaseline
pull requestthe base branch tip, the first parent of the test merge GitHub checks out
merge queuethe queue entry’s base_sha
push to masterthe tip the push replaced (github.event.before)
dispatch (e.g. the release PR)git merge-base HEAD origin/master

A red run therefore means this change moved a guarded crate’s API. A change that touches nothing under crates/, Cargo.toml or Cargo.lock has nothing to diff, so the check is skipped and the report says why. Nothing compares against the last release any more; release-plz does not run the tool either (see below), because rolter’s version follows commit types alone.

The baseline is checked out as a git worktree under the runner’s temp directory and reaches the tool as --baseline-root. --baseline-rev would copy the whole tree into target/semver-checks/, which rust-cache walks when it prunes the target directory, and it reports every tests/ directory in that copy as an ENOENT error. To reproduce a run locally, where no cache walks target/, --baseline-rev is fine:

cargo semver-checks -p rolter-balancer --baseline-rev "$(git merge-base HEAD origin/master)" --release-type patch

What this does not cover

The surfaces rolter’s users actually depend on are not Rust APIs:

  • the OpenAI- and Anthropic-compatible /v1/* gateway surface,
  • the control-plane REST API under /api/v1/*,
  • the configuration file keys and environment variables,
  • the database schema and its migrations.

None of those are checked here — cargo-semver-checks cannot see them. What each of them guarantees is decided in ADR-0032 (#922), and the user-facing statement of it is Versioning & compatibility.

The configuration-file half already has one property that a 1.0 promise will have to keep: rolter’s config types carry no deny_unknown_fields, so a rolter.toml written for any build loads on any other build of the same major, including an older one it is rolled back onto. That is why an unknown key can only ever be a warning, and why the warning lives in rolter check rather than in the deserializer — Unrecognised keys in rolter.toml covers what it reports and how --strict turns it into a CI gate.

The other half of what #922 needs is the list of subsystems the promise does not cover. That is a separate axis, set per subsystem rather than per crate, and it lives in Stability markers: an experimental marker is a documented exemption saying the subsystem may change shape or be removed in a minor release.

Renaming a public item on a guarded crate

The job reports a rename as inherent_method_missing — it cannot tell a rename from a deletion, and both read the same to anything outside the crate. So on a guarded crate a rename is not a free refactor, and it has exactly two honest endings:

  • Keep the old name as a deprecated shim. Leave the pub item in place, mark it #[deprecated(note = "use `<new name>`")], and have it delegate to the new one. The symbol is still in the API, the check stays green, and every in-tree caller moves to the new name in the same pull request. This is what rolter_proxy::Forwarder::forward_bearer is: #1446 renamed it to forward_mcp, which left the job red on every pull request until #1473 put the shim back.
  • Drop the old name and say so. Delete it outright and state in the pull request description that moving the API was the point. The job goes red, and that red is the record.

What is not an option is renaming quietly. The job is continue-on-error, so nothing blocks the merge, and it diffs against the commit the change lands on, so the red shows on that one pull request and never again. Left unexplained, it reads as noise and the move goes unrecorded. Do not add a since to the #[deprecated] unless the release that deprecates it is already known; release-plz picks the version from commit types, so a guessed one is wrong as often as not.

A shim is removed in a pull request whose title carries the ! and a BREAKING CHANGE: footer, once no caller is left.

Why the deprecation lint is allowed off

#[deprecated] is itself a minor-level change to cargo-semver-checks (type_method_marked_deprecated), and the guarded crates run at --release-type patch. Taken literally that makes the first ending above impossible: adding the attribute fails the same job that restoring the symbol was meant to fix, so the only green move would be a silent shim with no marker saying it is going away.

That reading is backwards here. The lint protects downstream crates from an unexpected compiler warning against a Rust API ADR-0032 says was never stable, while deprecate-then-delegate is precisely the behaviour worth encouraging on a guarded crate. So rolter-proxy turns that one lint off, in its own Cargo.toml:

[package.metadata.cargo-semver-checks.lints]
type_method_marked_deprecated = "allow"

Per-crate and per-lint on purpose. Nothing else is silenced — a removal, a signature change or a visibility change on any guarded crate still fails — and the next crate to deprecate something adds its own line rather than inheriting a workspace-wide exemption it never asked for.

At 1.0 — the check stays advisory

An earlier version of this page planned to promote the job at 1.0: drop continue-on-error, add it to ci-ok’s needs:, and revisit GUARDED against whatever 1.0 declared stable. ADR-0032 withdraws that plan, because 1.0 declares no Rust API stable. Every published crate is internal implementation detail, said so in its Cargo.toml description, its //! docs and the README, and a gate enforcing a promise the ADR disclaims would be worse than either choice alone.

So the check is permanently advisory:

  • Its three steps (the install, the baseline and the check) keep continue-on-error: true and run last in rust build, whose report turns a failure into a warning, never an error, so the check can never turn ci-ok red. A red run is a review signal — “this pull request moved a public item in a crate both binaries share” — and is often the correct outcome, as it is for the behaviour-preserving refactors in #1041 and #1042.
  • GUARDED keeps its four crates for the same reason: those are where an unintended API change is most likely to be an unintended behaviour change worth a second look. Adding or removing one is a judgement about review value, not about a promise, and still wants a line in the table above saying why.
  • release-plz has semver_check = false at the workspace level in .github/config/release-plz.toml. Left at its default it would run cargo-semver-checks itself and propose a major bump of the shared workspace version — every binary, the wheel, the Docker tag — because a shared helper changed shape. rolter’s version is driven by commit types and nothing else.

The check is green on a clean tree, and a red run means a guarded crate’s API moved. That is information, not a verdict.

Stability markers

rolter ships a wide surface, and not all of it is equally finished. Until #1385 nothing distinguished a subsystem an operator could build a deployment on from one that is a first cut: every screen and every endpoint presented with the same confidence, so the only way to find the thin parts was to read the source.

A stability marker says which parts are still moving. There are two levels:

LevelMeans
stablethe default. Covered by whatever compatibility promise the release makes
experimentalmay change shape or be removed in a minor release

The source of truth is SUBSYSTEMS in crates/rolter-core/src/stability.rs. Nothing re-lists it: the control plane serves it, the dashboard renders it, and a unit test in that module fails if the table below stops matching.

What the marker promises

experimental is a documented exemption, and the exemption is the whole point. It withholds exactly one guarantee: that the subsystem will still have the same shape, or exist at all, in the next minor release. Concretely, on an experimental subsystem a minor release may

  • rename, restructure or withdraw its API paths, request and response fields;
  • rename or drop its configuration keys and environment variables;
  • change what its stored rows mean, or stop reading them;
  • remove its dashboard screen.

None of that is licensed on a stable subsystem.

What the marker does not say

This is the half that gets misread, so it is stated positively:

  • It is not a quality warning. An experimental subsystem is not expected to crash, leak or lose data. It is held to the same review, test and security standards as everything else. The claim is about the design settling, not about the code being shaky — which is why the word is “experimental” and not “unstable”.
  • It is not a support disclaimer. A bug in an experimental subsystem is a bug and gets fixed.
  • It is not a schedule. Nothing here graduates on a date, and an entry may sit for several releases or be withdrawn instead. That is why the word is not “beta”, which reads as a time-boxed pre-release.
  • It is not “off”. Whether a subsystem runs is a different question entirely; see the axes below. Every subsystem in the table is enabled and usable in a default deployment.

The third axis

Stability composes with the two axes ADR-0031 proposes and is deliberately not a third mechanism inside them:

AxisQuestionSet byWhere it lives
capabilitycan this deployment run it?the build and its infrastructureunavailable_flags(), read-only
enablementis it turned on?the operatora stored flag, hot-reloaded through /internal/snapshot
stabilityhow finished is it?us, at build timeSUBSYSTEMS, a const

The three are independent. An experimental subsystem can be perfectly available and perfectly enabled, and marking one experimental never turns anything off.

The reason stability is a compile-time constant and not a database row is that it is a property of the code an operator received. If it were editable it could be set to a claim the running binary does not support, and it would have to be propagated, versioned and audited like any other config — for a value that can only change by upgrading.

ADR-0031 is still proposed at the time of writing, so this page does not depend on its code. It depends on its vocabulary, which is the part worth keeping consistent.

What is experimental in this build

Each entry earns its place by pointing at a gap the tree can be checked against — a documented “not yet”, a management surface the data plane does not read, an enforcement path a request can take without meeting. Not by impression.

SubsystemDashboardWhy it is experimental
labels(no screen; chips on Providers, Provider Groups and Routing Rules)Display and filter only. A route cannot select its targets by label, and making labels selectable turns them into configuration the data plane consumes — a change in what a label is. Stated in Labels.
mcp_settingsMCP → MCP SettingsThe screen stores organization defaults for transport, timeout intent, retries, failure policy and undeclared tools, and the HTTP proxy does not read them. Since #952 it does read a per-server override; a server without one falls back to the deployment-level transport timeouts rather than to these org defaults.
mcp_tool_groupsMCP → Tool GroupsTool-group manifests are stored and published to MCP-aware clients, but the proxy does not enforce group membership as an access boundary. Access is still decided by virtual-key owner, server, the server’s configured credential — a static one, or a live OAuth session and its required scopes — and nothing about which tools a group names. Stated in MCP OAuth.
pluginsPluginsThe webhook payload a plugin receives carries no version of its own, so the dispatch contract cannot change without silently breaking every endpoint already written against it. PluginRequest in plugin_dispatch.rs is the shape in question.
realtime(no screen; reachable from the Playground)Guardrails, the PII sanitizer and plugins do not run on /v1/realtime session events. A session is admitted against budgets and rate limits and metered per response turn (Realtime metering), but what it carries reaches the provider without a content check. Tracked in #1880.
skills_repositorySkills RepositoryA skill resolves only through the control-plane API; the gateway serves no skill surface, so how a client addresses and fetches one is not settled. Nothing outside the dashboard depends on the current shape yet.

A subsystem not in that table is stable. The table is short deliberately: a marker on every page is a marker nobody reads, and the value of this one is entirely in how rarely it appears.

Adding, changing or removing a marker

  1. Add, edit or delete the SubsystemStability row in crates/rolter-core/src/stability.rs. Keep the list sorted by id; the id is published and is never renamed once it has shipped.
  2. Update the table above in the same change. The the_docs_page_lists_exactly_these_subsystems test compares the two lists row for row, so a marker cannot ship without the paragraph that explains what it exempts the subsystem from.
  3. If the subsystem has a docs/user-docs/ page, put the note at the top of it, in the reader’s words rather than these.
  4. nav_keys are leaf keys from NAV in ui/src/lib/nav.tsx, checked by every_nav_key_is_claimed_once_and_exists_in_the_dashboard_nav. Leave it empty when the subsystem has no screen of its own; that is normal, not a gap.
  5. Add the note to the dashboard catalogs as stability.notes.<id> in ui/src/lib/i18n/locales/en.json and every sibling catalog, and drop it when the row goes. ui/scripts/stability-notes.test.ts (part of bun run test) fails while the en keys and the SUBSYSTEMS ids disagree, and bun run check:i18n holds every other locale to the en key set.

Why the dashboard does not render note

note on the wire is English, and a dashboard running in Russian used to show the translated word Экспериментально with a sentence of English behind it (#1401). The rail therefore uses only the id from the answer and looks the note up in the catalogs, where every other string it shows already lives. The list itself keeps one owner — which subsystems are marked, and on which nav entries, still comes from the control plane.

An id the dashboard’s catalogs do not know (in practice only a control plane newer than the bundle it serves) still gets its marker, just without the explanation. Nothing falls back to the English note: the marker carries the level, and the note is supplementary.

Graduating a subsystem is a deliberate act. Deleting a row says the shape is now something we will not change in a minor release, so it belongs in the pull request that closes the last gap the note names — not in a tidy-up sweep.

Granularity: why per subsystem

Per endpoint is more precise and rots faster: a route rename silently drops the marker, and a subsystem’s surface is usually several routes plus a screen plus a config block that all move together. Per dashboard screen cannot express anything without a screen, such as labels and realtime above.

Per subsystem is the same unit ADR-0031 uses for capability and enablement, so all three axes answer for the same thing. nav_keys then carries the mapping onto dashboard nav entries, one owner, zero, one or many keys per subsystem.

Where it is served

GET /api/v1/stability:

[
  {
    "id": "labels",
    "stability": "experimental",
    "note": "labels are display and filter only: no route can select its targets by label yet, …",
    "nav_keys": []
  }
]

Only the exceptions travel. A subsystem absent from the array is stable, which is what the dashboard renders nothing for — absence is the signal, and "stability": "stable" is never sent. An empty array is the healthy answer for a release with nothing experimental left in it.

It is its own endpoint rather than a field on an existing payload because the marker is read by a different question than any of them answers: feature-flags is superadmin-only and about enablement, and version is about the update check. The capability is stability:read, held by every authenticated caller including a viewer with no membership anywhere — the nav rail is rendered for everyone who can sign in, and a marker the least-privileged user cannot fetch is missing exactly where the reassurance is worth most. It names no tenant’s data, only what this build ships. The operation is getStability in the control-plane OpenAPI document, with a SubsystemStability schema, so an SDK author sees it too.

Relationship to the 1.0.0 guarantees (#922)

ADR-0032 decides what each stable surface guarantees at 1.0 — the /v1/* gateway surface, the /api/v1/* control API, the config keys and environment variables, the database schema and the Rust crates. This page does not restate it. It is named there as the exemption route: a subsystem carrying the experimental marker is outside every promise the ADR makes, and no second mechanism exists beside it.

What this page does is make that table writable. A 1.0 that promises compatibility across the whole surface either over-promises or is held hostage to the least-finished corner of the product; naming the exemptions is the honest, cheap third option. Each row above is one line #922 does not have to argue about. API stability and the semver gate is the same argument for the Rust crates.

Configuration export and the import round trip

rolter-seed --import and rolter config export are the write and read halves of one round trip. crates/rolter-control/src/seed.rs consumes a rolter.toml; crates/rolter-control/src/config_export.rs produces one. They are coupled on purpose, and the coupling is the thing to keep intact.

The invariant

Import a file, export it, re-import the export, export again: the two exports are byte-identical.

config_export::tests::round_trip asserts exactly that against a scratch postgres schema. It is the acceptance test for the feature, and it fails the moment the two halves disagree — a section the exporter writes that the importer drops, or a value that renders differently on the second pass.

Adding a section

A new section belongs in both modules or in neither:

  1. seed.rs — an upsert keyed by the section’s slug or natural key, following the desired-state rules the module doc states (never delete a row the file does not mention, never rewrite a slug, never touch a sealed credential).
  2. config_export.rs — a render_* function emitting the same keys, sorted.
  3. config_export::tests::round_trip::FIXTURE — a row of the new kind, so the round trip actually covers it.
  4. The HEADER constant, which tells the operator what round-trips and what does not. A section added to the exporter and not to the header is a silent promise.
  5. docs/user-docs/configuration/config-export.mdx, whose table mirrors the header.

Exporting something the importer ignores is worse than not exporting it: the file then looks like a complete description of the deployment while a re-import quietly drops part of it.

The stamp and the shape move together

The export writes schema_version = <CURRENT_SCHEMA_VERSION> and ADR-0022’s tiered [[providers.readonly]] / [[providers.default]] spelling. Neither may be changed without the other, and the reason is not stylistic (#1513).

The migration chain selects steps with MIGRATIONS.iter().filter(|m| m.from >= from). A file stamped 2 that still contained the deprecated flat [[providers]] arrays would therefore skip the v1 -> v2 step forever. It would load correctly today, because split_section accepts both spellings, and then misbehave under the first v2 -> v3 migration written against the tiered shape — a failure that arrives one release after the mistake, in a step whose author did nothing wrong.

an_exported_config_has_no_pending_migrations and the_export_uses_the_tiered_provider_spelling pin the two halves. Source the stamp from rolter_core::config_migrate::CURRENT_SCHEMA_VERSION, never as a literal.

Emitting both tiers is also what keeps the export lossless. The flat array is the readonly tier — it cannot express providers.default at all — so exporting a deployment with seeded defaults used to drop them silently. the_default_tier_round_trips covers that direction.

Why some tables are out

  • Budgets and rate limits are keyed by the database id of the org, team, project or virtual key they cap (BudgetConfig::id). A file carrying those ids is not portable, which contradicts the one identity rule the format has: slug or public model name, never a database id. Exporting them needs a scope-slug resolution layer on both sides first.
  • Virtual keys exist in the store only as a one-way digest.
  • Prompt-template activation scopes are database ids too. Only the route-model bindings survive; an org-wide template exports with no routes list, which is what the importer already reads as “all routes”.

Secrets

ConfigStore::load returns providers with api_key decrypted — the gateway needs the plaintext, so the snapshot path has to carry it. The exporter therefore renders provider credentials from api_key_env only and never reads api_key at all. config_export::tests::a_decrypted_provider_key_never_reaches_the_document pins that against the emitted bytes rather than by review.

A provider with no api_key_env gets a comment where the credential would be, so the omission is visible in the artifact.

Both spellings, one column

ProviderConfig accepts the credential as either api_key/api_key_env or the plural api_keys array. Every persistence path — the exporter, rolter-seed --import and the [[providers.default]] seed — goes through ProviderConfig::api_key_env_name() for the variable name and inline_api_key() for the literal, so the spelling a document happens to use makes no difference to what is stored or exported. Reading api_key_env directly is what made a plural-form provider import with no credential at all (#1514).

The split between the two accessors is the safety property: api_key_env_name() returns a variable name and is the only one an exported document can reach, while inline_api_key() returns a secret and has exactly one caller, the seal into provider_keys.

providers.api_key_env is a single column and provider_keys holds one sealed row per provider, so a multi-key provider is not representable in the store. The importer keeps the first entry naming an env and warns with the count it dropped rather than failing the import; #1720 covers normalising the spellings and must not imply that the array itself round-trips.

Packaging & distribution

rolter ships three ways.

The unified rolter binary dispatches to both planes via subcommands:

rolter gateway --config rolter.toml     # data plane
rolter control --database-url postgres://…   # control plane + UI host

The standalone rolter-gateway / rolter-control binaries remain available.

cargo

cargo install rolter            # unified launcher (from crates.io)
# or from source:
cargo install --path crates/rolter

uv (PyPI wheel via maturin)

The wheel bundles the compiled rolter launcher so Python users can install the CLI with uv. pyproject.toml uses the maturin backend (bindings = "bin", manifest-path = crates/rolter/Cargo.toml).

Each release publishes five wheels plus a source distribution:

artifactbuilt on
manylinux…x86_64ubuntu-latest, target: x86_64
manylinux…aarch64ubuntu-latest, target: aarch64
macosx…arm64macos-latest (Apple Silicon, native)
macosx…x86_64macos-latest, cross-compiled target: x86_64-apple-darwin
win_amd64windows-latest
.tar.gz (sdist)ubuntu-latest, command: sdist

The macOS x86_64 wheel is cross-compiled rather than built on an Intel runner — the macOS SDK carries both architectures, so it needs no extra runner. The sdist is the fallback for anything with no matching wheel: without one, pip install rolter fails outright on an unlisted platform instead of building from source. verify-parity asserts all six are present for the version, so a silently missing platform fails the release rather than reaching a user.

Those builds run only after crates.io has published and the tag is pushed, so a packaging mistake found there leaves a release with no wheel. The rust build job in quality.yml catches most of them on the PR: its maturin sdist step, run from the repo root, parses pyproject.toml and [tool.maturin], resolves manifest-path, the bindings, the readme and the license, and packages the workspace path dependencies. It takes seconds because it compiles nothing, so a failure that appears only when maturin compiles a wheel for a release target still shows up first in build-wheels.

uv tool install maturin       # one-time
uvx maturin build --release   # build a wheel into target/wheels/
uv tool install rolter        # once published to PyPI

Docker

Multi-stage docker/Dockerfile builds the Rust binaries and the Bun-built UI, then assembles a slim runtime:

docker build -f docker/Dockerfile -t rolter:dev .
docker compose -f docker/docker-compose.yml up -d          # full stack with postgres/redis/clickhouse

The version line

The workspace is on 0.1.0 (Cargo.toml, [workspace.package]), and every crate inherits it. The line matters because release-plz derives the next version from Cargo’s SemVer compatibility rules rather than from the commit type, and those rules change meaning below 1.0.0:

Current versionfix:feat:breaking (!)
>= 1.0.0patchminormajor
0.x.y (x >= 1) — todaypatchpatchminor
0.0.zpatchpatchpatch

rolter sat on 0.0.z until #501, where every commit type collapsed to a patch bump: a release could never express that a feature or a breaking change had landed. 0.1.0 restores that signal for breaking changes while deliberately withholding the stable-API promise 1.0.0 carries — a feat is still a patch until the 1.0.0 milestone closes and the version line moves again.

Moving the line is a one-time manual edit of [workspace.package] version plus the matching version = "…" on each internal [workspace.dependencies] entry (they are path+version deps so the published crates are not wildcards). release-plz picks the new line up on the next Release PR and auto-bumps from there.

The helm chart’s appVersion

charts/rolter/Chart.yaml carries two numbers and they mean different things. version: is the chart’s version, on its own cadence, and nothing here touches it. appVersion: is which rolter a chart release deploys — the value helm list prints and most dashboards surface — so it must equal the workspace version.

Nothing kept it there. It sat at 0.0.9 while the workspace shipped 0.0.10, 0.0.11 and then 0.1.0, pointing operators at a version that had never been deployed (#1140). Two things now hold it:

  • the release-plz pr job runs scripts/sync-chart-appversion.py --fix on the release branch and pushes the result, so the Release PR is already consistent
  • a helm chart step of quality.yml’s static checks job runs the same script in check mode, so a disagreement fails CI rather than shipping — including if the release-branch step ever stops working

The script is also a prek hook on Cargo.toml and Chart.yaml, so a manual version-line move is caught before it is pushed. To reconcile by hand:

python3 scripts/sync-chart-appversion.py --fix

Release pipeline

Releases are automated from Conventional Commits, apart from one step: a maintainer labels the release PR release:ready when it should go out (see cutting a release). Two workflows do the release work and ci.yml gates the release PR; the handoffs between them are the part worth understanding, because each one is a workflow_dispatch call made to get around GitHub’s event suppression rather than an ordinary trigger.

merge to master
      │
      ▼
release-plz.yml ── release-pr ──►  "Release PR" (version bump + changelogs)
      │                                    │
      │                                    │ a maintainer adds the release:ready label
      │                                    │
      │                                    │ workflow_dispatch --ref <release branch>
      │                                    ▼          ← release-pr-ready.yml, on the label
      │                                 ci.yml        ← dispatch-release-pr-ci, on each
      │                                    │            later push while it stays
      │                                    ▼
      │                          ci-ok on the Release PR
      │
      │ (that PR is merged)
      ▼
release-plz.yml ── release-gate ──► release ──►  crates.io publish
                   (ci-ok of this sha's          tag v{version}
                    ci.yml push run)             github release
      │
      │ workflow_dispatch -f tag=v{version}      ← dispatch-artifact-release
      ▼
release.yml
  │
  ├─ gate ──── verify-external-checks (ci-ok + CodeQL green for the tagged sha)
  │
  ├─ build ─── build-wheels  (5 wheels + sdist)
  │            build-image   (per arch, pushed as untagged digests)
  │
  ├─ smoke ─── smoke-wheels  (install each wheel, run `rolter --version`)
  │            smoke-image   (run each image digest, check its version)
  │
  ├─ publish ─ publish-pypi    (trusted publishing, OIDC)
  │            publish-docker  (assemble tag manifests: GHCR + Docker Hub)
  │
  └─ check ─── verify-parity  (all channels serve {version})

The stages are a barrier, not decoration. Every publish job depends on every build and smoke job, so a release is all-or-nothing: a failed wheel can no longer leave container images published against a version that has nothing on PyPI. Before this split, publish-docker did not depend on build-wheels at all, and exactly that partial release was possible.

Two properties make the barrier real:

  • Images are built as untagged digests (push-by-digest). A digest nobody can resolve by tag is not a release; publish-docker only assembles the :{version} and :latest manifests once everything else has passed, from those same digests — so the image is never rebuilt.
  • Nothing is published untested. The smoke stage installs each wheel and runs each image digest, asserting rolter --version matches the tag, while the artifacts are still private. The wheel install uses --no-index, so it can only resolve from the freshly built dist/ and can never pass by silently pulling an older rolter from PyPI.

Each build is its own job, so a single flaky platform can be re-run on its own without re-publishing anything that already succeeded.

Cutting a release

release-plz keeps one release PR open, on a branch named release-plz-<timestamp>, and rewrites it on every push to master. Nothing runs ci.yml on it until a maintainer decides the release should go out:

  1. Add the release:ready label to the release PR:

    gh pr edit <number> --add-label release:ready
    

    release-pr-ready.yml runs on that label event and dispatches ci.yml on the release branch. release-plz updates the same PR in place, so the label survives its force-pushes, and every later push to master that moves the branch dispatches ci.yml again through dispatch-release-pr-ci for as long as the label stays.

  2. Wait for ci-ok on the release PR, then merge it. The merge commit’s own push run then gates the crates.io publish (below).

  3. To hold back a release that was labelled too early, remove the label. The head that already has ci-ok stays mergeable, but the next push that rewrites the branch gets no dispatch, so the PR goes back to BLOCKED.

If the label produced no ci.yml run, dispatch it by hand:

gh workflow run ci.yml --ref <release branch>

That covers a failed release pr ready run, and a release PR with a merge conflict, on which GitHub starts no pull_request workflow at all. release-plz rebuilds the branch from master on every push, so a conflict there is rare.

The crates.io publish waits for the push run

release-plz release publishes to crates.io, pushes the tag and creates the GitHub release with a persisted contents: write token, so it must never start on a commit nothing verified (ROL-103). It needs the release-gate job and runs only when that job’s verified output is true. release-gate holds a read-only token (contents: read, actions: read) and does two things:

  1. scripts/unpublished-crates.sh lists every workspace crate whose publish is not [] in cargo metadata (today everything except rolter-ui) and looks its version up on crates.io. When every version is already there, which is every push except the merge of the release PR, the job prints a notice and stops without setting verified, so release-plz release is skipped. A lookup that fails for any reason counts as unpublished.
  2. Otherwise scripts/wait-for-ci-gate.sh waits for the ci.yml push run on the same commit (actions/workflows/ci.yml/runs?head_sha=<sha>&event=push&branch=master) and requires that run’s ci-ok job to have concluded success. It fails closed on an API error that survives three attempts, on any other conclusion, on a finished run with no ci-ok job, and after 90 minutes. Its last command writes verified=true to the step output, and the step runs that script and nothing else, so only a successful exit sets it.

The gate is bound to a run, not to a check-run name. Any workflow can post a check-run called ci-ok on a master commit: a pull_request run from master into another branch reports on master’s head, and workflows fired by outsiders write check-runs there too. A ci.yml push run on a sha can only come from ci.yml at that sha, and a later push never cancels it. release.yml’s verify-external-checks still matches check-run names (below); the two are separate mechanisms.

The publish is bound to the gate, not to the detector. A detector that wrongly reports nothing pending leaves verified unset, which delays a release but never publishes one unverified. scripts/check-release-handoff.py asserts that binding, that the wait step is the bare script call, and that the script’s one write of verified is its last command. scripts/test-release-gate.sh runs both scripts against a fake gh, curl, cargo and clock, checking that the output is written exactly when the wait exits 0, as a step of quality.yml’s static checks job and as a prek hook.

The job waits rather than re-running quality.yml on the merge commit, which cost 27 jobs on every push while ci.yml’s own push run was gating the same sha. A push with nothing to publish now costs one short job. A release push holds one runner, and the release-plz concurrency group, for as long as the push gate takes; the 90-minute deadline leaves room above the slowest push gate measured (61 minutes).

A timeout or a red ci-ok fails release gate and skips the publish. Get ci-ok green on that push run first (re-run its failed jobs if the failure was a flake), then re-run the gate and everything after it:

gh run rerun <release-plz run id> --failed

If a later push’s release-plz run starts first, it finds the version still unpublished, gates its own commit and publishes from that later tree, which is verified the same way.

Why the release PR needs a dispatch too

The same suppression hides the release PR itself. ci-ok is the only required status check on master, and only ci.yml reports it — but release-plz pushes the release branch and opens its PR with the repository GITHUB_TOKEN, so neither push nor pull_request fires and the required context is never reported. The PR stays at BLOCKED no matter how long you wait, and the only way to merge it is an admin bypass. Every release from the introduction of ci-ok up to #1025 went out that way; the only checks that ever landed on a release PR were the externally-sourced ones (the CodeQL app, GitGuardian), because those do not come from a workflow event.

dispatch-release-pr-ci closes it with the same tool as the tag handoff. It runs after release-plz-pr — so it sees the head that the sync chart appVersion step leaves behind, not the one before it — finds the open PR whose head branch starts with release-plz- and lives in this repository, and, if that PR carries the release:ready label, runs gh workflow run ci.yml --ref <branch>. A workflow_dispatch run’s check-runs attach to the head commit of the ref, which is exactly the commit branch protection is looking at. Like the tag handoff, the job fails if the dispatch produces no run.

Before #2025 it dispatched on every push to master. Each of those runs gated a PR nobody was about to merge, and the next push often cancelled it part-way: 11 of the 33 release-PR dispatches between 2026-09-21 and 09-28 were cancelled mid-flight, and on 09-28 the dispatches took 16.7% of the day’s runner time (ADR-0034). The label moves that cost to the moment someone intends to merge. The check fails closed. No open release PR, or one without the label (a listing that shows no labels at all included), means no dispatch, and the PR stays BLOCKED until someone adds the label. An API error fails the job.

A label added by a person is not a GITHUB_TOKEN event, so it does start a workflow, and release-pr-ready.yml is the one it starts. It runs on pull_request (labeled) and dispatches only for release:ready on a release-plz- branch of this repository; every other label event skips its one job without taking a runner. The job holds actions: write, checks nothing out and uses no action. It uses pull_request rather than pull_request_target because a fork’s pull_request run gets a read-only token whatever the job asks for, while pull_request_target would hand it a write token; zizmor’s dangerous-triggers audit rejects the latter. A branch of this repository can only come from someone who can already push workflows, so running that branch’s copy of the workflow grants nothing new.

It deliberately does not read the branch from the action’s prs output: that output is populated only on the run that creates the PR, while the branch is force-pushed on every later master commit and needs re-gating each time.

One check is genuinely absent on a dispatched run: ci-ok’s pr-title step runs only on pull_request, so it is skipped, and the verdict tolerates that skip with a ::warning:: saying the title went unvalidated. That is acceptable here because release-plz writes the release PR title itself and it is already a valid Conventional Commit line.

Why the explicit dispatch

release.yml also has a push: tags trigger, but it never fires for a real release. release-plz creates the tag with the repository GITHUB_TOKEN, and GitHub suppresses downstream workflow events for token-created refs to prevent recursive runs. workflow_dispatch is the documented exception — it always creates a run, even from the GITHUB_TOKEN — so release-plz.yml ends with a dispatch-artifact-release job that calls gh workflow run release.yml -f tag=vX.Y.Z. That job holds actions: write and nothing else, and it fails if the dispatch produces no run.

Without it the pipeline half-works in the worst way: the GitHub release and crates.io advance while no wheel is ever built, and every job stays green. That is how v0.0.6 through v0.0.10 shipped while PyPI sat on 0.0.5 (#903). scripts/check-release-handoff.py (a merge gate in quality.yml and a prek hook) asserts the wiring is still in place, the crates.io publish gate above included. It reads release-plz.yml, release-pr-ready.yml, release.yml and ci.yml as parsed YAML (and scripts/wait-for-ci-gate.sh as shell text) and checks structure: a job exists, its needs set holds the required job ids, a trigger or dispatch input is declared. It also holds both release-PR dispatches to the same label name, so renaming release:ready in one workflow fails the check instead of leaving one path that never fires, and it keeps release-pr-ready.yml free of any action, a checkout included. The few assertions that are text by nature, a gh workflow run command or the push-by-digest=true option, look only inside the job they belong to, and they read its shell with the comments removed: a dispatch commented out to pause releases fails the check rather than passing it. A job or step switched off with if: false counts as missing. So the check holds however the files are laid out, prettier’s reflow included. The gh api run confirmations keep event=workflow_dispatch in the URL path; any other query field is accepted as a -f flag only when the call pins -X GET. Run it with uv run --script scripts/check-release-handoff.py; --self-test breaks each invariant in a copy of the workflows and fails unless the matching check catches it, which is how a check edited into one that can never fail gets noticed.

Which tag the dispatch carries

releases, the output release-plz hands back, contains a tag for every published crate — not only the one with git_tag_enable. The crates.io-only members get a derived <crate>-v<version> string that was never pushed to git. Only rolter-gateway is configured with git_tag_name = "v{{ version }}", so resolve release tag selects that entry by package name; an empty result means nothing was released on this push and the dispatch job is skipped.

Taking the first tagged entry instead is what broke v0.0.11 (#1026): the array starts with rolter-core, the dispatch carried rolter-core-v0.0.11, and release.yml failed at checkout on a ref that does not exist — so v0.0.11 went to crates.io and GitHub Releases with no wheel, and PyPI stayed on 0.0.10. The step now also rejects any resolved tag that is not vX.Y.Z, so a bad ref fails before it is dispatched rather than halfway through the artifact build.

Parity gate

verify-parity runs with always() at the end of release.yml and asserts that every enabled channel actually serves the tagged version: the GitHub release exists, crates.io has rolter {version}, PyPI has it (when PYPI_PUBLISH_ENABLED is true), and GHCR has the manifest (when DOCKER_PUBLISH_ENABLED is true). A skipped or failed publish turns the run red instead of quietly leaving a channel behind.

Publishing gates

GateEffect
release-gate (release-plz.yml)crates.io publish, tag and GitHub release wait for ci-ok on the commit’s ci.yml push run; fail-closed
release:ready label on the release PRci.yml runs on the release PR only while it is present, so without it the PR stays BLOCKED
verify-external-checksci-ok and CodeQL recorded success for the tagged commit; fail-closed
RELEASE_REQUIRED_CHECKS repo variableexact check-run names verify-external-checks requires (comma-separated)
PYPI_PUBLISH_ENABLED repo variablemust be "true" or the PyPI publish is skipped
DOCKER_PUBLISH_ENABLED repo variablemust be "true" or the image publish is skipped
pypi environmentPyPI trusted publishing via OIDC; no long-lived token is stored

Wheels are built with maturin-action but uploaded with pypa/gh-action-pypi-publish: maturin upload is deprecated and slated for removal (PyO3/maturin#2334). The publisher identity PyPI matches on is the repository, workflow filename and environment — not the tool — so the swap is transparent to the trusted-publisher config, and it adds PEP 740 attestations on the id-token grant the job already holds.

The gate is asserted, not re-run

release.yml does not run quality.yml itself. It asserts that the tagged commit already passed it, by requiring ci-ok among the check-runs recorded for that SHA. That is deliberate, and it is what makes the gate correct:

A local reusable workflow (uses: ./…) always checks out the caller’s ref. On a workflow_dispatch the caller ref is master, while build-wheels checks out inputs.tag — so a re-run verified master and shipped the tag (#988). It passed, and told you nothing about what was being packaged. Threading the tag into quality.yml fixes that but makes the shared workflow check out an arbitrary dispatch-supplied ref in a default-branch context, whose caches trusted runs later restore — cache poisoning, and CodeQL flags it.

Asserting settles both. Every commit on master carries a ci-ok check-run from ci.yml, and release-plz tags only after release-gate has seen ci-ok succeed on the release commit’s ci.yml push run (above), so a tagged commit is verified by construction. The assertion binds to the tagged SHA — which re-running never did — costs no duplicate 20-minute run, and checks out nothing.

On the release-plz path the loop normally passes on its first poll: release-gate saw ci-ok finish before the tag existed, and ci-ok needs every CodeQL leg. A tag dispatched by hand, or a check re-run after tagging, can still leave a required check pending, and that is expected, not a failure: the job waits up to 45 minutes for a verdict, fails immediately on a real non-success, and fails closed if a required check never appears.

RELEASE_REQUIRED_CHECKS holds exact check-run names, so it rots whenever a scanner is renamed or reconfigured — and since the gate is fail-closed, a stale name silently blocks every release instead of failing at the source. This bit rolter once already: the variable still named the CodeQL default setup jobs (Analyze (rust), …) after the repo moved to advanced setup (codeql (rust), …), so no release could publish even with a working tag dispatch. If the gate reports “required check … not found”, compare it against the check-run names the job log prints and update the variable.

Releasing a tag by hand

For a backfill, or if a dispatch was lost, run the artifact half yourself from the default branch:

gh workflow run release.yml -f tag=v0.0.10
gh run watch "$(gh run list --workflow=release.yml --limit 1 --json databaseId -q '.[0].databaseId')"

The upload uses --skip-existing, so re-running a tag that partly published is safe. Verify the result:

curl -s https://pypi.org/pypi/rolter/json | jq -r .info.version
uv tool install rolter && rolter --version

Backfill policy

Only the current release is backfilled to PyPI. The versions the broken handoff skipped (v0.0.6 – v0.0.9) stay unpublished: they are superseded pre-1.0 releases, uv tool install rolter / pip install rolter resolve to the latest version regardless, and publishing them retroactively would put four versions on the index that no user ever pinned, dated years after their tags. Anyone who needs one of them can build from the tag or cargo install rolter@0.0.x from crates.io, which has the complete series.

Competitive study: an open-source gateway worth learning from

A read-only study of experientiallabs/experiential (Apache-2.0), carried out to find gaps in rolter. It is a comparison, not a plan of record: the ideas it recommends are tracked as GitHub issues, and the ones it rejects are recorded here so they do not get re-proposed every quarter.

The study was done against commit a3da051 (2026-09-17), reading the public README, SETUP.md, docs/, and the package layout. Nothing was copied.

What it is

A Python-packaged gateway and router for agent workflows, installed with pip install experiential and driven by one CLI (exp). Its three claims:

  1. one OpenAI-compatible API over hosted, BYOK and local models;
  2. per-identity control of which models may be used and how much may be spent;
  3. turning production traffic into a router (or a fine-tuned model) optimised for quality, speed and cost.

Shape of the thing:

  • Data plane — a compiled native (Rust) component, exp-gateway-native, published as a wheel. It binds loopback only, accounts into SQLite, and serves Chat Completions, Responses (including a Responses-over-WebSocket transport for the Codex CLI), Anthropic Messages plus count_tokens, Embeddings, Image generations, and a typed decision endpoint of their own design.
  • Control surface — Python. Identities, virtual keys, grants, aliases, exact model pools, monthly limits, guardrail policies, and the optimiser.
  • Optimiser — the distinctive half. Ingest traces, build a simulation, fit a routing policy against it, evaluate on held-out evidence, emit a report. An optional path fine-tunes an open-weights model through a third-party service.
  • Hosted platform — a managed gateway with credits, instant email signup, and an llms.txt contract so a coding agent performs the whole onboarding.
  • Scale posture — single-node, loopback, SQLite. Their own research note documents a shared-SQLite durability ceiling and tells operators to scale horizontally instead.

The single sharpest observation from the whole exercise: they treat onboarding as a machine-consumed API. SETUP.md is four self-contained prompts a user pastes into Claude Code or Cursor; the agent then creates the account, stores the key, wires the SDK, and repoints the user’s other agents. Adoption cost is one paste. rolter’s adoption cost is reading a quickstart.

Feature-by-feature

AreaThemrolterVerdict
OpenAI surfaceChat Completions, Responses (+WebSocket), Embeddings, Images, decisionsChat Completions, Responses, Embeddings, Messages, audio, realtime, MCProlter ahead on breadth; they have the Codex WebSocket transport and count_tokens (tracked in #1016)
Anthropic surfaceMessages + count_tokens (estimated, with a disclosure header)MessagesGap: count_tokens (already in #1016)
Provider kindsOpenAI, Anthropic, Gemini, Azure, Bedrock, Vertex, Fireworks, OpenRouter, openai-compatibleSubstantially more, plus self-hosted engines, TEI, llama.cpp, Ollamarolter ahead
BYOKKeys stored in a user-data file outside the repo; catalog stays secret-freeKEK-sealed in Postgres, rotation and a restore audit, env-var indirectionrolter well ahead
Model catalogSecret-free models.toml; every alias declares capabilities and prices explicitlyDashboard catalog, pricing overrides, model defaultsComparable; price import still manual in rolter (#966)
RoutingOrdered exact-model pools with per-rung conditional failover by failure classStrategies (weighted, cache-aware, adaptive, complexity, disaggregated), circuit breakers, retries, cooldownsrolter far ahead on live routing; behind on conditional failover
Cost intelligenceRouter fitted offline from real traffic, with held-out evaluation and a reportAdaptive blend uses live cost as a scoring signal; #1468 plans an evaluation harnessGap: no offline “what would this have cost” analysis
Model recommendationYes — the fitted router is the recommendationNoGap
Trace ingestNine declared sources: OTLP, OTel GenAI, PostHog, Braintrust, Langfuse, LangSmith, Mastra, Phoenix, chat-jsonNone — rolter only sees traffic it servedGap
ObservabilityContent-free SQLite accounting, loopback usage view, OTLP inClickHouse request logs, MCP logs, cost attribution, alerting, tenant telemetry destinations, dashboardsrolter far ahead
GuardrailsIdentity-scoped, default-off, input+output chains, pluggable classifiersGuardrail rules and guardrail providers as first-class resourcesComparable; rolter has the richer admin surface
BudgetsMonthly integer nano-USD enforcement; a cost preflight that refuses an expensive operation before it dials a providerBudgets, limits, cost attribution; reservations planned in #1464Comparable, converging
AuthZIdentities, grants, virtual keysFull RBAC matrix, custom roles, access profiles, SSO/OIDC, SCIM, LDAP, TOTP, audit logrolter far ahead
DashboardHosted platform only; local is a terminal UI60+ screens, i18n, Storybook, capability gating, loading/empty/error contractsrolter far ahead
OnboardingInteractive first-run wizard, then agent-pasteable prompts and llms.txteasy-up, fake-llm, a written quickstart; no wizard, no llms.txtBehind
Deploymentpip install, loopback, SQLite, or their hosted platformDocker, Helm, Kubernetes, air-gapped, cluster mode, backup/restore/KEK rotationrolter far ahead
Docs~17 dense reference pages, very precise, contract-flavoured; no tutorialsmdBook developer docs + Mintlify user docs, 32 ADRs, nav gates in CIrolter ahead on breadth and navigation; their per-page precision is worth imitating
MarketplaceHosted model marketplace with credits and instant signupNone, and none wantedOut of scope by design

Where rolter is plainly ahead

Worth stating so the study is not read as a list of deficiencies. rolter is a multi-tenant, horizontally scalable, operator-owned gateway; theirs is a single-node developer tool with a hosted upsell. rolter wins outright on multi-tenancy and RBAC, storage and the control/data-plane split, the dashboard, MCP as a governed surface, semantic and cache-aware routing, alerting, audit, air-gapped and Kubernetes deployment, key sealing and rotation, API stability guarantees, and the ADR trail behind all of it. None of the ideas below trade any of that away.

Ideas worth stealing, ranked

Each is filed as a GitHub issue (#1581 through #1586); the ranking is value against effort.

  1. A savings advisor over rolter’s own request logs (#1581). rolter already stores every invocation with model, tokens, latency and price. It has never turned that into a sentence an operator can act on: “38% of traffic on this route was short, tool-free and single-turn; the same requests on the cheaper model in the pool project to $X/month.” This is their central claim, achievable in rolter without any of their machinery, because rolter’s logs are richer than the traces they have to import. Highest value, moderate effort.
  2. llms.txt plus agent-pasteable setup prompts (#1582). Make a coding agent able to stand rolter up: one machine-readable contract served by the docs site and a handful of self-contained prompts. Very low effort for a real drop in adoption cost, and it composes with the agent-CLI routing epic (#1012).
  3. Import LLM traces from external observability tools (#1583). OTLP first, then the common vendor exports. It makes the advisor useful on day zero — before a single request has gone through rolter — which is exactly when a prospect is deciding. Depends on (1) to be worth anything.
  4. Per-target conditional failover (#1584). rolter’s fallback order is positional and its retry class is global. Letting a target declare which failure classes it may take over (throttling only; refusals only; a named refusal category only) turns a fallback pool into a policy, and is the one routing idea they have that rolter does not.
  5. A first-run setup wizard in the dashboard (#1585). Connect a provider, create a route, mint a key, make the first call — with the built-in fake-llm as a zero-credential first step. rolter’s zero-config story is genuinely good and the dashboard does not tell it.
  6. Disclose altered request parameters on the response (#1586). When the gateway drops or rewrites a request field for compatibility, say so in a response header instead of silently succeeding. Small, and it removes a class of “why did my top_k do nothing” support question.

Ideas to reject, and why

  • Fine-tuning open-weights models from traffic. A different product with a different operational burden (GPU budgets, model hosting, dataset governance). rolter routes; it does not train.
  • Simulation and world-model machinery. Their router fitting is built on a simulated environment mined from traces. The evidence-first framing in #1468 is the right depth for rolter; the simulator is not.
  • A hosted marketplace with credits and instant email signup. rolter is self-hosted by design, and instant account creation over an unauthenticated endpoint is a posture rolter should not adopt.
  • Anonymous product telemetry on by default. They send aggregate PostHog events unless disabled. rolter ships air-gapped and tenant-scoped telemetry destinations; default-on phone-home would contradict both.
  • A typed decision endpoint of their own invention. Non-standard surface area. rolter’s compatibility promise is that clients written for OpenAI and Anthropic work unchanged.
  • Single-node SQLite accounting. Their own benchmark note names the durability ceiling. rolter already made the other choice.
  • Portable project bundles. rolter’s config export/import round trip covers the real need and is already documented.

Licensing and attribution

Their code is Apache-2.0. Ours is not theirs to relicense, and theirs is not ours to absorb.

  • No code is copied, in whole or in part, translated or paraphrased. Nothing in this study was derived by reading an implementation file; it is all from the public README and reference docs.
  • No wording is copied. Their documentation prose, error strings, CLI help and product copy stay theirs. Every string that ships in rolter is written fresh.
  • Naming. The project is named in this study and in issue bodies, which is the correct place for it. It is not named in ui/src, in any user-facing string, in docs/user-docs/, or in any shipped artefact.
  • Ideas are not the code that expresses them. “Fit routing from recorded traffic” and “declare which failure classes a fallback target accepts” are approaches, and rolter will express them in its own design against its own data model.
  • If any concrete artefact of theirs is ever wanted verbatim — a schema, a test vector, a fixture — it is an Apache-2.0 intake decision with a NOTICE obligation, taken deliberately in its own PR and not inside a feature branch.

A note on placement

This page lives under development/ rather than a research/ section because docs/research/ is not a tracked directory in this repository — it is local scratch. If a research section is ever created, this page moves with it.

Zero-config quickstart: no keys, add providers at runtime

rolter starts with zero LLM credentials and serves the built-in fake-llm model out of the box. Real providers, models, and upstream API keys are added later — at runtime, over the management API, persisted in Postgres, and picked up by the gateway without a restart.

1. Start with no credentials

rolter easy-up

That’s it. No provider keys, no database, no config file (one is created from the bundled example on first run). The gateway answers immediately:

curl http://localhost:4000/v1/chat/completions \
  -H "Authorization: Bearer sk-rolter-dev" \
  -H "Content-Type: application/json" \
  -d '{"model": "fake-llm", "messages": [{"role": "user", "content": "hi"}]}'

sk-rolter-dev is the local-dev virtual key from the generated rolter.toml; delete the [[virtual_keys]] section to run open, or replace it before exposing the gateway anywhere.

easy-up prints this exact command at startup, with the key read back out of the config it just wrote rather than hardcoded — so an edited key, a renamed key or a deleted [[virtual_keys]] section all still print something that runs as shown (#1615). In database mode there is nothing truthful to print: virtual keys live in the store, the seed mints none, and a database-backed gateway treats an empty key set as locked rather than open. There the hint carries $ROLTER_API_KEY and says to mint a key on the dashboard’s Virtual Keys screen first.

2. Switch on runtime management (Postgres mode)

Runtime CRUD over providers/models/keys needs the database-backed control plane:

export ROLTER_ADMIN_TOKEN="$(openssl rand -hex 24)"   # protects the management API
export ROLTER_KEK="$(openssl rand -hex 32)"           # encrypts provider keys at rest

rolter easy-up --database-url postgres://user:pass@localhost:5432/rolter

easy-up migrates, seeds a default org/team/project, imports the bootstrap toml, and starts both planes. The gateway port (4000) now also serves the management API: /admin/* proxies to the control plane’s /api/v1/*.

Two deployment secrets matter here:

  • ROLTER_ADMIN_TOKEN — bearer token required on the management API and the internal snapshot endpoint. Without it those endpoints are open (fine on localhost; a startup warning reminds you).
  • ROLTER_KEK — key-encryption key. Provider API keys submitted over the API are sealed with AES-256-GCM before they reach Postgres; the KEK never leaves the process environment. Set the same value on the control plane and gateway (with easy-up it is one process, so one export). Without a KEK, requests that include an api_key are rejected — there is no plaintext fallback.

3. Add your first real provider — with its key — via curl

BASE=http://localhost:4000/admin

# ids seeded by easy-up
ORG=$(curl -s $BASE/orgs -H "Authorization: Bearer $ROLTER_ADMIN_TOKEN" | jq -r '.[] | select(.name=="default") | .id')
TEAM=$(curl -s $BASE/orgs/$ORG/teams -H "Authorization: Bearer $ROLTER_ADMIN_TOKEN" | jq -r '.[0].id')
PROJECT=$(curl -s $BASE/teams/$TEAM/projects -H "Authorization: Bearer $ROLTER_ADMIN_TOKEN" | jq -r '.[0].id')

# provider + upstream credential (sealed at rest; never returned by the API)
PROVIDER=$(curl -s -X POST $BASE/orgs/$ORG/providers \
  -H "Authorization: Bearer $ROLTER_ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"name": "openai", "kind": "openai", "api_base": "https://api.openai.com", "api_key": "sk-..."}' \
  | jq -r .id)

# public model name + target
ROUTE=$(curl -s -X POST $BASE/projects/$PROJECT/routes \
  -H "Authorization: Bearer $ROLTER_ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"model": "gpt-4o", "strategy": "round_robin"}' | jq -r .id)

curl -s -X POST $BASE/routes/$ROUTE/targets \
  -H "Authorization: Bearer $ROLTER_ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d "{\"provider_id\": \"$PROVIDER\", \"upstream_model\": \"gpt-4o\"}"

Within the snapshot poll interval (instantly with --redis-url) the gateway serves the new model — no restart:

curl http://localhost:4000/v1/models -H "Authorization: Bearer sk-rolter-dev"

4. Rotate or remove a credential

PUT /admin/providers/{id} updates a provider in place. For api_key, api_key_env, and egress_proxy: omit the field to leave it unchanged, send an empty string to clear it, send a value to set/rotate it.

# rotate
curl -X PUT $BASE/providers/$PROVIDER \
  -H "Authorization: Bearer $ROLTER_ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"api_key": "sk-new-key"}'

# remove the stored key (falls back to api_key_env, if set)
curl -X PUT $BASE/providers/$PROVIDER \
  -H "Authorization: Bearer $ROLTER_ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"api_key": ""}'

How the pieces fit

  • Persistence — providers/routes/keys live in Postgres; credentials in provider_keys, AES-256-GCM-sealed with the ROLTER_KEK-derived key.
  • Propagation — every write bumps config_version (a database trigger, transactional with the write). Gateways poll GET /internal/snapshot?version=N and hot-swap their routing snapshot; with Redis configured the control plane also publishes a bump for instant refetch. See Config & hot reload.
  • Two surfaces, one API — /admin/* on the gateway is a thin reverse proxy to the control plane’s /api/v1/* (enable on a standalone gateway with --admin-url http://control:4001); pointing tooling at either works identically. Authentication is enforced by the control plane in both cases.
  • Config file still wins — anything declared in the bootstrap rolter.toml is a read-only “config model” (LiteLLM-style): the API rejects runtime mutations to it with 409.
  • Reads never leak secrets — GET /api/v1/config (the dashboard read) redacts api_key; only the token-guarded snapshot endpoint carries decrypted keys, because the gateway needs them to call upstreams.

Configuration reference

The gateway boots from a TOML file (--config, default rolter.toml); see rolter.example.toml. At runtime, the control plane is the source of truth and applies changes without a restart (config-and-hot-reload.md).

Schema

[server]

  • host (string, default 0.0.0.0)
  • port (u16, default 4000)
  • metrics_path (string, default /metrics) — path the Prometheus metrics endpoint is served on; change it to avoid colliding with an upstream app or sidecar that already owns /metrics. Must be rooted (/…) and must not collide with a built-in route (/healthz, /v1/*).

[tls]

  • ca_bundles (string[], default []) — PEM CA-bundle files added to the normal public-root trust store for outbound upstream TLS. ROLTER_CA_BUNDLE replaces this global list with a single deployment-local path. Files are checked for missing, unreadable, empty, and malformed content while config is loaded.

[[providers]]

  • name (string, unique) — referenced by route targets
  • kind (openai | anthropic | openai_compatible | ollama | ollama_cloud | llama_cpp | openrouter | tei | azure_openai | bedrock | vertex | gemini | gemini_native | gemini_interactions | mistral | groq | xai | meta_llama_api | cohere | perplexity | together | fireworks | databricks | aleph_alpha | nebius | ovhcloud | scaleway | deepseek | qwen | zhipu | kimi | ernie | doubao | hunyuan | yi | minimax | baichuan | gigachat | yandex_gpt | cloud_ru | mts_ai | naver | upstage | rinna | rakuten | sarvam | krutrim | falcon)
  • api_base (string) — base URL, no trailing slash
  • api_key (string, optional) — prefer api_key_env
  • api_key_env (string, optional) — environment variable to read the key from
  • role_profile (openai | system_only | anthropic, optional) — explicit instruction-role semantics. The default is openai for kind = "openai", anthropic for kind = "anthropic", and conservative system_only for every OpenAI-compatible kind. system_only converts leading developer messages to system in place; it rejects a system or developer message after a user/assistant/tool turn with role_capability_unsupported rather than silently changing it.
  • model_role_profiles (table, optional) — upstream-model-specific role_profile overrides. Use this only for a custom template whose developer-role support is explicitly known; rolter never probes a vLLM template at runtime.

Role-capability profiles

openai_compatible describes the HTTP surface only. vLLM, in particular, renders roles using the selected model’s chat template, so an endpoint’s role support must not be inferred from its /v1 API. The default system_only profile is suitable for Qwen-style templates that do not define developer. Set role_profile = "openai" or a model_role_profiles entry only after confirming that the deployed template supports distinct developer messages.

Anthropic targets collect leading OpenAI developer and system messages into ordered top-level system blocks. Instruction messages placed after a conversation turn are rejected for anthropic and system_only profiles; rolter returns an OpenAI-style 400 with code role_capability_unsupported instead of dropping or reclassifying them.

Ollama: local daemon vs Cloud

Use ollama for a local/self-hosted daemon such as http://localhost:11434 (no authentication). Use ollama_cloud for direct programmatic Cloud access. Cloud requires api_key_env (normally OLLAMA_API_KEY); inline keys and key pools are rejected. Configure api_base = "https://ollama.com"; rolter uses the OpenAI-compatible /v1/chat/completions and /v1/models endpoints with bearer authentication. Ollama’s native /api/* endpoints are distinct.

[[providers]]
name = "ollama-cloud"
kind = "ollama_cloud"
api_base = "https://ollama.com"
api_key_env = "OLLAMA_API_KEY"

Azure OpenAI, Amazon Bedrock, and Vertex AI

These providers use their current OpenAI-compatible APIs. Set api_base to the provider’s OpenAI-compatible prefix and use an environment-sourced credential:

[[providers]]
name = "azure"
kind = "azure_openai"
api_base = "https://RESOURCE.openai.azure.com/openai/v1"
api_key_env = "AZURE_OPENAI_API_KEY"

[[providers]]
name = "bedrock"
kind = "bedrock"
api_base = "https://bedrock-runtime.us-east-1.amazonaws.com/v1"
api_key_env = "AWS_BEARER_TOKEN_BEDROCK"

[[providers]]
name = "vertex"
kind = "vertex"
api_base = "https://aiplatform.googleapis.com/v1/projects/PROJECT/locations/global/endpoints/openapi"
api_key_env = "VERTEX_ACCESS_TOKEN"

Azure credentials are sent in the api-key header. Bedrock and Vertex credentials are sent as bearer tokens. The default active-health probes use Azure’s model list, Bedrock ListFoundationModels, and Vertex’s publisher model list, respectively; none invokes a model.

Google Gemini, Mistral, Groq, and xAI (Grok) expose hosted OpenAI-compatible APIs. Their api_base already carries the version segment, so rolter strips the leading /v1 from the gateway path before appending it. Keys are bearer tokens sourced from api_key_env (inline keys are rejected); the free health probe lists {api_base}/models.

gemini_native targets Gemini’s native generateContent surface instead of its OpenAI-compatible shim. rolter translates OpenAI Chat / Anthropic Messages / OpenAI Responses requests into Gemini’s contents/parts wire format and converts the response (and SSE stream) back, so clients keep speaking their usual protocol. The model and method are embedded in the URL ({api_base}/models/{model}:generateContent, or :streamGenerateContent?alt=sse for streaming), the key is sent as x-goog-api-key, and api_base points at the version root with no /openai suffix.

gemini_interactions targets Gemini’s stateful Interactions API. Every chat-shaped request is translated onto the single {api_base}/interactions endpoint: turns become input items, system/developer messages become system_instruction, tool calls and tool results become function_call / function_result items, and sampling parameters become generation_config. The response steps[] (and the interaction.created / step.delta / interaction.completed SSE events) are converted back into the client’s dialect. The thread is client-driven: the interaction id is returned as the response id, and a client resumes it by sending previous_response_id (OpenAI Responses) or previous_interaction_id — rolter keeps no interaction state of its own. Auth is x-goog-api-key, and api_base points at the version root. Endpoints with no interactions equivalent (embeddings, audio) are rejected rather than forwarded.

[[providers]]
name = "gemini"
kind = "gemini"
api_base = "https://generativelanguage.googleapis.com/v1beta/openai"
api_key_env = "GEMINI_API_KEY"

# native generateContent wire format (translated from OpenAI/Anthropic)
[[providers]]
name = "gemini-native"
kind = "gemini_native"
api_base = "https://generativelanguage.googleapis.com/v1beta"
api_key_env = "GEMINI_API_KEY"

# stateful Interactions API (translated from OpenAI/Anthropic/Responses)
[[providers]]
name = "gemini-interactions"
kind = "gemini_interactions"
api_base = "https://generativelanguage.googleapis.com/v1beta"
api_key_env = "GEMINI_API_KEY"

[[providers]]
name = "mistral"
kind = "mistral"
api_base = "https://api.mistral.ai/v1"
api_key_env = "MISTRAL_API_KEY"

[[providers]]
name = "groq"
kind = "groq"
api_base = "https://api.groq.com/openai/v1"
api_key_env = "GROQ_API_KEY"

[[providers]]
name = "xai"
kind = "xai"
api_base = "https://api.x.ai/v1"
api_key_env = "XAI_API_KEY"
  • [[providers.api_keys]] (optional) — multiple weighted API keys for one provider; when present it takes precedence over the single api_key/api_key_env pair. Providers cap throughput per key, so rotating across keys multiplies effective RPM/TPM. The control-plane store holds one credential per provider, so rolter-seed --import keeps the first entry that names an env and warns about the rest
    • key (string, optional) — inline key value; prefer env
    • env (string, optional) — environment variable to read the key from
    • weight (u32, default 1) — relative selection weight
  • api_key_env (string, optional) — env var to read the key from
  • egress_proxy (string, optional) — legacy single HTTP/HTTPS/SOCKS5 outbound proxy; treated as a one-element pool
  • egress_proxies (string[], optional) — round-robin HTTP, HTTPS, SOCKS5, or SOCKS5H proxy pool. A connect/tunnel failure retries the next member; three consecutive failures quarantine a member for 30 seconds. Authenticated proxy URLs must be supplied as whole-value environment references such as "${PROVIDER_PROXY_EU}", keeping credentials out of config snapshots and database/API output
  • ca_bundles (string[], optional) — provider-specific replacement for global [tls].ca_bundles; [] explicitly selects public roots only
  • [providers.kv_events] (optional) — vLLM V1 ZMQ KV-event source for precise_cache_aware: endpoint (tcp://…), topic (default kv-events), max_blocks (default 1,000,000), and stale_secs (default 30)
  • [providers.lmcache] (optional) — LMCache controller signal for lmcache_aware: endpoint (HTTP JSON occupancy signal), refresh_secs (default 2), and stale_secs (default 10)
  • also_track_via_llm_call (bool, default false) — when set, active health checks send a real max_tokens = 1 completion to this provider instead of the free /v1/models liveness probe, so a healthy result proves end-to-end inference. This burns a few tokens on every sweep (interval_secs); leave it off unless you need inference-level health. Recorded as source = llm_call in provider_health_events.
  • llm_probe_model (string, optional) — the upstream model id the also_track_via_llm_call completion targets (e.g. gpt-4o-mini). Required when the flag is on; without it (or an api key) the checker logs a warning and falls back to the free probe.
  • status_page_url (string, optional) — statuspage.io-style status.json URL (e.g. https://status.anthropic.com/api/v2/status.json). When set, a slow background poll records the provider’s public status as a secondary status_page health signal — it surfaces in provider_health_events, the dashboard and rolter_status_page_degraded_total, but never marks the provider unhealthy or affects routing on its own. Parse/transport failures are logged and skipped.

See Custom CA bundles for rotation behavior and Docker/Kubernetes mount examples.

[[routes]]

  • model (string) — public model name clients request
  • strategy (round_robin | random | power_of_two | consistent_hash | cache_aware | weighted | pipeline | cheapest | fastest | precise_cache_aware | lmcache_aware | adaptive | lora_aware | predicted_latency, default round_robin)
  • [[routes.targets]]
    • provider (string) — a provider name
    • model (string, optional) — upstream model id; defaults to the requested model
    • weight (u32, default 1)
  • [routes.params] (table, optional) — admin default inference params injected into the request body (e.g. temperature, max_tokens, stop). Provider-agnostic: keys are whatever the upstream accepts. An unset param passes through untouched.
  • [routes.param_policy] — whether callers may override the params defaults
    • mode (allow | deny, default allow) — baseline override policy
    • allow (string[], default []) — params callers may override when mode = "deny"
    • deny (string[], default []) — params callers may not override when mode = "allow"
    • when an override is denied and the caller sends the param anyway, the admin default silently wins
  • [[routes.variants]] (optional) — weighted variants for A/B, canary, and key-split traffic. When present, the route ignores the top-level targets pool: a request samples one variant by weight (the primary) and, on failure, falls over to the remaining variants in declared order. Within a variant the route’s strategy picks which target leads; the remaining targets follow in declared order as the deterministic fallback tail.
    • name (string) — variant identifier, attributed in request logs (the variant column)
    • weight (u32, default 1) — relative traffic share for the primary draw
    • [[routes.variants.targets]] — same shape as [[routes.targets]]
    • [routes.variants.params] (table, optional) — variant-scoped param defaults, layered over [routes.params] (the variant wins) under the route’s param_policy
  • [routes.advanced.guardrails] (optional) — per-route selection layered over the global [guardrails] rule set. Rules are named, so adding one globally still reaches every route that has not opted out of it by name.
    • disable (string[], default []) — rules that do not apply on this route
    • enable (string[], default []) — rules that apply on this route; wins over disable on a conflict
    • a name matching no configured rule fails validation rather than being ignored — a typo in disable would otherwise read as “this rule is off here” while the rule kept running
    • the dashboard’s Guardrail Rules screen names, on each rule’s card, the routes whose override switches it off, read from GET /api/v1/config (offRoutes in ui/src/lib/guardrail-policy.ts mirrors RouteGuardrails::allows)

[[virtual_keys]]

  • key (string) — the bearer token clients present
  • name (string, optional)
  • models (string[], default []) — allow-list; empty = all

[adaptive_routing]

Deployment-wide policy for routes using the adaptive strategy. See load balancing.

  • enabled (bool, default false) — kill switch; while off, every adaptive route serves the pipeline stack
  • latency_weight (f32, default 1.0), cost_weight (f32, default 0.5), load_weight (f32, default 0.25) — blend weights; negatives are clamped to 0, and all-zero disables the blend
  • exploration_ratio (f32, default 0.05) — share of picks made at random to keep latency samples fresh; clamped to [0, 0.5]
  • min_samples (u32, default 50) — requests a route must serve before the blend engages

[logging]

  • clickhouse_url (string, optional)

[health]

  • enabled (bool, default false) — master switch for active upstream probing
  • interval_secs (u64, default 10) — seconds between probe sweeps
  • timeout_secs (u64, default 2) — per-probe timeout
  • path (string, default /) — probe path; the default resolves to each provider kind’s free liveness endpoint (normally /v1/models, or the provider-native Azure, Bedrock, or Vertex model-list endpoint)
  • probe_concurrency (usize, default 2) — max probes in flight at once during a sweep, so probing never stampedes upstreams
  • consecutive_failure_threshold (u32, default 3) — consecutive probe failures before a provider is marked unhealthy
  • recovery_success_threshold (u32, default 2) — consecutive successes before an unhealthy provider recovers
  • status_page_interval_secs (u64, default 60) — seconds between provider status-page polls; only providers with a status_page_url are polled, and the poller runs even when enabled = false
  • probes are jittered across the first quarter of the interval (per-provider stable offset), and a 429 on the probe itself pauses that provider’s probing with exponential backoff (1, 2, 4, 8 sweeps) without marking it unhealthy

[realtime]

Resource limits and metering cadence for persistent /v1/realtime WebSocket sessions. The three limits are per gateway process; set one to 0 to disable it.

These are the gateway’s own resource guards, not tenant policy. A session also meets the deployment’s budgets, unpriced_policy and rate limits at admission, and is metered per response turn while it runs; see Realtime metering.

  • max_connections (u64, default 1000) — concurrent sessions admitted by this gateway instance
  • max_session_secs (u64, default 3600) — hard session-duration limit
  • idle_timeout_secs (u64, default 300) — closes a session when neither side sends a frame
  • usage_flush_secs (u64, default 15) — how often a live session’s finished response turns are written to the request log, added to budgets and tpm windows, and followed by a budget re-check. It bounds how long a session can overspend a budget before it is closed. 0 flushes after every turn instead of on a timer, and re-checks the budgets every second while the session is quiet; metering cannot be switched off

[egress]

Where the gateway may send upstream traffic. A provider’s api_base (and any egress_proxy) is operator-supplied, so without a destination policy a crafted or compromised provider row turns the gateway into an SSRF primitive pointed at whatever sits inside its network position — cloud instance metadata being the classic target.

  • block_link_local (bool, default true) — deny 169.254.0.0/16 and fe80::/10. This is the cloud instance-metadata range; leave it on unless you have a concrete reason
  • block_loopback (bool, default false) — deny 127.0.0.0/8 and ::1, plus the unspecified addresses 0.0.0.0 and ::, which reach the local host when connected to. Off by default because sidecar and single-host deployments legitimately serve models on localhost
  • block_private (bool, default false) — deny 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 and fc00::/7. Off by default because on-prem clusters are the common case
  • allow_hosts (string[], default []) — hosts exempt from every check above (an IP literal or a hostname). An entry matches the address the URL reaches, whatever the spelling, so 169.254.169.254 also covers http://0xa9fea9fe/. The escape hatch for a deployment that must reach one otherwise-denied address

Enforcement happens twice. IP literals are classified during config validation, so a bad api_base is rejected at startup or when the control plane writes it. A URL is read the way the HTTP client reads it, so every spelling of an address is classified as that address: http://2852039166/, http://169.254.169.254./ and http://[::ffff:169.254.169.254]/ are all 169.254.169.254. Hostnames are classified at connect time, against the address DNS actually returned — validation never resolves DNS itself (that would make config validation depend on a live resolver and break air-gapped deployments), and a connect-time check is what makes the policy DNS-rebinding-safe. A denied address fails the request; it is not silently retried elsewhere.

[egress]
block_link_local = true
block_private = true
allow_hosts = ["vllm.internal.example.com"]  # the one private upstream we mean to reach

[guardrails]

Built-in, zero-dependency regex guardrails and PII redaction, evaluated inside the gateway with no external service and no network hop. Disabled by default; a disabled or empty block adds no hot-path cost. Complements — never replaces — the custom guardrail webhook (ROL-257) and external PII engines (ROL-258).

  • enabled (bool, default false) — master switch
  • max_scan_bytes (usize, default 262144) — cap on total request text scanned per request; oversized content is forwarded unscanned so work stays bounded
  • streaming_post_call (string, default reject) — what a streamed request does on a route that has post_call rules; see Output masking and streaming

Each [[guardrails.rules]] entry:

  • name (string, required) — stable, unique; surfaced in metrics, never carries match text
  • builtin (string) — one of email, phone, api_token, payment_card; or pattern (string) for a custom regex. Set exactly one.
  • stage (string, default pre_call) — pre_call scans request content before proxying; post_call masks the response body before it reaches the client
  • action (string, default annotate) — annotate (count only, forward unchanged), block (reject with an OpenAI-compatible guardrail_blocked error), or redact (replace each match with replacement)
  • replacement (string) — redaction token; defaults to the built-in entity token (e.g. [REDACTED:EMAIL]) or [REDACTED]
  • include_system (bool, default false) — also scan operator-authored system/developer messages; excluded by default

default_on was removed. It documented a per-request client opt-in that was never implemented, so it never selected anything: every configured rule applied regardless of its value. Existing configs that still set it keep loading — the key is ignored — and behaviour is unchanged. To turn a rule off somewhere, name it in that route’s [routes.advanced.guardrails] disable list (see [[routes]]).

Patterns use the linear-time (RE2-style) regex engine with no catastrophic backtracking, and are compiled under a bounded program size during config validation — an invalid or unbounded pattern fails at startup/snapshot validation, never on the request path. The request path never logs raw matched values; metrics expose rolter_guardrail_blocks_total and rolter_guardrail_redactions_total only.

Scanned surfaces: OpenAI /v1/chat/completions and /v1/responses (messages + input), /v1/completions (prompt), and Anthropic /v1/messages (system + messages). String, string-array, and typed text parts are all covered.

[guardrails]
enabled = true

[[guardrails.rules]]
name = "email"
builtin = "email"
action = "redact"

[[guardrails.rules]]
name = "card"
builtin = "payment_card"
action = "block"

[[guardrails.rules]]
name = "leaked-key"
builtin = "api_token"
stage = "post_call"
action = "redact"

Output masking and streaming

A post_call rule runs on the response body after the upstream replies and before the client sees it. redact rewrites the matched text in place; block withholds the completion and returns a 403 with code guardrail_blocked and the rule name — nothing was malformed and no upstream failed, so it is neither a 400 nor a 5xx, and it stays out of the range clients retry on.

Masked surfaces: choices[].message.content and choices[].text (OpenAI chat and legacy completions), the top-level content parts array (Anthropic /v1/messages), and output[].content[].text plus output_text (/v1/responses).

Tool-call arguments are scanned too. An address the model passes to a send_email tool has left the completion just as surely as one it printed. Arguments that arrive as a JSON document inside a JSON string (choices[].message.tool_calls[].function.arguments, the legacy function_call.arguments, and /v1/responses output[].arguments) are parsed, masked and re-encoded, so a replacement token containing a quote or a backslash cannot turn valid arguments into something the client fails to parse; non-string values are untouched. Anthropic tool_use parts are masked through their input object. Arguments that are not valid JSON are masked as plain text.

Output masking requires the whole completion. A match can straddle any number of token boundaries, so a rule redacting a@b.com cannot act on a frame holding only a@b, and buffering the full stream would remove the only property streaming has. streaming_post_call therefore decides what a streamed request does on a route with post_call rules:

  • reject (default) — refuse with a 400 carrying code guardrail_streaming_unsupported, counted in rolter_guardrail_stream_rejections_total. The default fails closed: a masking rule that silently stops applying because the client passed "stream": true is the failure mode worth ruling out.
  • passthrough — serve the stream with output rules not applied. pre_call rules still run on the request.

The dashboard’s Guardrail Rules screen reads the effective value from GET /api/v1/config and states its consequence beside the Before response stage and on every post_call rule’s card, or says the value could not be read. It has no control for the setting.

Non-streamed responses are buffered by the gateway when (and only when) a post_call rule applies to the route, so a route without them keeps its existing forwarding behaviour. Cached responses are stored as the upstream returned them and masked on every delivery, not once at store time — so a rule added after an entry was cached still applies to it, and an entry shared by two routes is masked per the route serving it. Output metrics: rolter_guardrail_output_redactions_total and rolter_guardrail_output_blocks_total.

[guardrail_webhook]

A vendor-neutral hook to a self-hosted semantic guardrail service (e.g. Guardrails AI, LLM Guard). Before proxying, the gateway POSTs a stable JSON envelope to the configured endpoint; the service replies with an allow/block/transform/annotate decision. Disabled by default; complements the built-in regex guardrails.

  • enabled (bool, default false) — master switch
  • url (string) — http(s) endpoint the envelope is POSTed to (required when enabled)
  • stage (string, default pre_call) — pre_call inspects the request. post_call is validated but not yet enforced (output/SSE stage deferred).
  • timeout_ms (u64, default 2000) — per-call timeout
  • max_retries (u32, default 0) — extra attempts on a transient failure (connect/timeout/non-2xx)
  • failure_mode (string, default fail_open) — fail_open forwards unchanged when the service is unreachable; fail_closed rejects with an OpenAI-compatible error
  • max_body_bytes (usize, default 65536) — cap on the content forwarded; oversized content is sent as a truncated preview with truncated: true
  • auth — optional credential resolved from the environment at call time, never inlined:
    • { bearer = { token_env = "GUARD_TOKEN" } } → Authorization: Bearer <env>
    • { shared_secret = { secret_env = "GUARD_SECRET" } } → X-Rolter-Guardrail-Secret: <env>

Contract. Request envelope: { direction, stage, model, route, trace_id, tenant: { org, team, project, key }, truncated, content }. Only these fields are sent; prompt content is never logged by the gateway. Response: { "action": "allow" | "block" | "transform" | "annotate", "content"?, "reason"?, "annotations"? }. An unrecognized or malformed decision defaults to allow (transport failures are governed by failure_mode). Metrics: rolter_guardrail_webhook_blocks_total, _transforms_total, _errors_total; the trace id is propagated in the X-Rolter-Trace-Id header.

[guardrail_webhook]
enabled = true
url = "https://guard.internal/check"
failure_mode = "fail_closed"
auth = { bearer = { token_env = "GUARD_TOKEN" } }

[prompt_templates]

Centrally-managed, versioned prompt templates and deterministic route decorators (ROL-256). Applications reuse approved system instructions through a named template without being granted arbitrary prompt-authoring privileges. Disabled by default; an empty or disabled block adds no hot-path cost.

  • enabled (bool, default false) — master switch

Each [[prompt_templates.templates]] entry is one immutable version:

  • id (string, required) — stable identifier surfaced in safe metadata, never in content logs
  • version (u32, required, ≥ 1) — immutable version; the operator lists exactly the versions to activate. (id, version) must be unique.
  • routes (array of string, default all) — public model names this template applies to; empty means every route
  • [[prompt_templates.templates.variables]] — a named variable a decorator may reference as {{ name }}:
    • name (string, [A-Za-z_][A-Za-z0-9_]*)
    • required (bool, default false) — the caller must supply it; mutually exclusive with default
    • default (string) — value used when the caller omits it
  • [[prompt_templates.templates.decorators]] — a message injected around the caller’s own messages:
    • role (string, default system) — system, assistant, or user
    • position (string, default prepend) — prepend (before the caller’s messages) or append (after), both in declared order
    • content (string) — message text, with optional {{ variable }} placeholders

Variables and escaping. Callers pass values in a rolter_template_vars object on the request body; it is always stripped before forwarding upstream. A caller value overrides the declared default; an unknown variable, a missing required variable, or an oversized value (variable > 4 KiB, rendered message > 16 KiB) is rejected with an invalid_prompt_template error. Substitution is structural: each rendered message is emitted as a JSON string through the serializer, never string-concatenated into raw JSON, so a variable value can never break out of its string or inject additional messages. Every {{ placeholder }} is validated at config-load time to reference a declared variable.

Surfaces and ordering. Applied to /v1/chat/completions, /v1/responses, and Anthropic /v1/messages. Prepend decorators wrap before, append after, preserving the caller’s own message order and semantics. For Anthropic, system decorators fold into the top-level system field (joined by blank lines); assistant/user decorators wrap the messages array. Surfaces without a chat message array (e.g. /v1/completions) are not decorated. The gateway applies only the configured immutable version from its reload-free snapshot. Applied template id/version and decoration count are recorded in safe metadata; metrics expose rolter_prompt_template_decorations_total and rolter_prompt_template_rejections_total.

[prompt_templates]
enabled = true

[[prompt_templates.templates]]
id = "support-preamble"
version = 3
routes = ["gpt-4o"]

[[prompt_templates.templates.variables]]
name = "persona"
default = "a helpful support assistant"

[[prompt_templates.templates.decorators]]
role = "system"
position = "prepend"
content = "You are {{persona}}. Follow the company policy and be concise."

A caller then supplies variables per request:

{
  "model": "gpt-4o",
  "messages": [{ "role": "user", "content": "hi" }],
  "rolter_template_vars": { "persona": "a billing specialist" }
}

When the control plane uses PostgreSQL, prompt templates can also be authored through the org-scoped /api/v1/orgs/{org_id}/prompt-templates endpoints. Draft versions are immutable once published, publication and rollback update the reload-free snapshot, and activation can be limited to an organization, project, route, or virtual key. Seed import creates missing versions idempotently and refuses to overwrite an existing immutable version with different content.

Organization skills use the adjacent /api/v1/orgs/{org_id}/skills endpoints. A skill has mutable metadata and access policy, immutable inline or reference-backed versions, explicit publish/rollback/retire lifecycle, and a deterministic /resolve/{slug} endpoint. Reference-backed versions accept https, git+https, oci, and s3 references without embedded credentials; secret-bearing metadata keys are rejected.

Environment variables

  • ROLTER_CONFIG, ROLTER_HOST, ROLTER_PORT — gateway. rolter easy-up binds its gateway to ROLTER_HOST too, and its control plane unless ROLTER_CONTROL_HOST is set (loopback when neither is); the published image’s default command passes --host 0.0.0.0 instead
  • ROLTER_CONTROL_HOST, ROLTER_CONTROL_PORT, ROLTER_UI_DIR — control plane, under rolter control and rolter easy-up alike
  • ROLTER_KEK — AES-256-GCM KEK for provider-secret encryption
  • ROLTER_PUBLIC_URL — the control plane’s externally reachable base URL (default http://localhost:4001). The OIDC redirect URI is derived from it, so single sign-on needs it set correctly behind a proxy; see Single sign-on. Read once at startup, so a change needs a restart
  • ROLTER_DATABASE_URL, ROLTER_REDIS_URL, CLICKHOUSE_URL — datastores (ClickHouse alone is read unprefixed)
  • RUST_LOG — tracing filter (e.g. info, rolter_gateway=debug)
  • provider key vars referenced by api_key_env (e.g. OPENAI_API_KEY)

CLI flags override env, which override file values.

Every rolter setting is read under its ROLTER_ prefix; an unprefixed name is read by nothing, and an unread variable is not an error. The docs, the Helm chart and .env.example once named the Redis and Postgres URLs without the prefix, which shipped chart-deployed gateways with no Redis (#1771). crates/rolter/tests/env_var_names.rs now fails when an operator-facing file names a multi-part ROLTER_<NAME> setting as bare <NAME>; a shell variable such as $ADMIN_TOKEN in a curl example is exempt.

Secure configuration (rolter init and rolter check)

rolter has two deployment paths, and they are deliberately not converging.

Local (rolter easy-up) is frictionless and may stay loose on security: no database, no provider keys, no secrets at all, answering immediately on the built-in fake-llm model. It prompts for nothing, and rolter check does not change that.

Production is guided and should be hard to misconfigure. rolter check validates a production deployment before the process starts, so a misconfiguration fails loudly instead of starting degraded.

rolter init                       # generate a production config and its secrets
rolter check                      # validate the current environment
rolter check --config rolter.toml # also parse and validate the config file
rolter check --strict             # treat warnings as failures too
rolter check --connect            # also probe that the datastores accept a connection

The two are a pair. check tells an operator what is wrong; init tells them what to write. A test in the tree renders a fresh production environment and runs the full check suite over it, so what the generator emits is exactly what the gate accepts — if they ever drift, an operator would follow the documented path and still be told their deployment is broken.

It exits non-zero when anything fatal is found, which makes it usable as a container entrypoint step, a Kubernetes init container, or a Helm pre-install hook.

Why this exists

The motivating case is worth stating plainly, because it is the shape of every problem this command is meant to catch.

Provider credentials are encrypted at rest with a key-encryption key read from ROLTER_KEK. When that variable is missing, the control plane’s default-provider seed does not fail — it logs a warning and stores the inline api_key unsealed. Nothing goes red. The deployment serves traffic normally. A credential the operator believed was encrypted at rest simply is not, and there is no signal saying so.

Until recently several docs pages — including the production deployment guide and .env.example — told operators to set ROLTER_MASTER_KEY, a variable nothing in the codebase has ever read. An operator who followed those instructions exactly ended up in precisely that degraded state. Those pages are now corrected, and rolter check reports the mistake by name if the old variable is still set anywhere.

That is the class of failure this gate targets: not a crash, but a deployment that looks healthy while quietly not doing what it was configured to do.

What it checks

CheckSeverityWhy
ROLTER_KEK presenterrorWithout it provider credentials are stored unsealed, with only a warning
ROLTER_KEK length ≥ 16errorThe KEK is stretched through SHA-256, so a weak secret still yields a valid — and brute-forceable — key
ROLTER_ADMIN_TOKEN presenterrorOtherwise the management API and /internal/snapshot are unauthenticated
ROLTER_DATABASE_URL present and postgres://errorOtherwise the control plane falls back to an in-memory store and loses all config on restart
No example values surviveerrorThe example database credentials and the e2e throwaway KEK are published in the repository
ROLTER_REDIS_URL presentwarningWithout it rate limits and budgets are enforced per replica, not per deployment
Control plane not bound to 0.0.0.0warningThe management API should not be reachable on every interface
ROLTER_KEY_PEPPER presentwarningWithout it a leaked virtual-key digest is usable as-is against this deployment
ROLTER_SESSION_PEPPER presentwarningWithout it session, invitation, SCIM-token and recovery-code digests are unpeppered, so a database write is enough to plant a session, and the failed-login throttle’s Redis keys carry no deployment salt
CORS does not allow *errorThe dashboard is served same-origin; a wildcard lets any page a logged-in operator visits drive the management API as them
Datastores accept a connection (with --connect)error / warningA URL that parses but resolves to nothing fails at first use rather than at rollout
ROLTER_KEK opens the store (with --connect, postgres builds)errorEvery other KEK rule reads the environment alone, so all of them pass on a database restored under a different KEK — a failure with no startup symptom at all
Config file parses (with --config)errorA config that fails to load leaves the gateway on whatever it last had
Every key in the config file is recognised (with --config)warningAn unknown key is ignored rather than rejected, so a typo is silence and a default rather than an error. Also logged as a WARN by every process that loads the file — see below

--connect is opt-in because a check that opens sockets cannot be the default for a command meant to run offline and without side effects. It is a TCP connect, not a protocol handshake: it needs no database driver and answers the question that actually goes wrong during a rollout — the name does not resolve, or nothing is listening. A datastore that accepts TCP but rejects the credentials is a different failure, and one the process reports loudly on its own.

The KEK-against-the-store check is the one exception to “TCP connect, not a protocol handshake”: it opens a one-connection pool, samples up to 25 rows per sealed column and tries to decrypt them. It never runs migrations — a check asked to inspect a database must not change it — and it reports counts, never plaintext. A store that holds no sealed secrets yet is reported as neither verified nor broken, because an empty store would let any KEK look correct. The backup and restore runbook covers what to do when it fires, and rolter kek verify runs the same audit on its own.

Redis and the bind address are warnings rather than errors because a single-replica deployment behind an ingress is legitimately fine without either. Use --strict in an environment where they are not.

The two peppers are warnings because a deployment without them works. For the session pepper the reasoning is concrete: session, invitation and SCIM tokens are 256 bits of CSPRNG output and recovery codes 80, all far past brute force, so a leaked digest cannot be inverted with or without it, and what goes missing is the defence in depth. Both peppers are expensive to add late, though. Setting ROLTER_KEY_PEPPER on a deployment that ran without one invalidates every virtual key already issued. Setting ROLTER_SESSION_PEPPER on one signs everyone out and voids open invitations, SCIM tokens and MFA recovery codes, so users regenerate their codes and each SCIM client needs a new token. rolter init writes both from the start, which is the cheap time to have them. Under --strict, which the Helm chart’s init container uses, a missing pepper blocks the rollout like any other warning.

Anything resembling credentials in a URL is redacted before printing, since this output routinely lands in CI logs.

Unrecognised keys in rolter.toml (#1424)

None of rolter’s config types carry serde’s deny_unknown_fields, and that is a deliberate, load-bearing choice rather than an oversight. It is what makes the configuration file forward and backward compatible: a rolter.toml written against a newer build stays loadable by an older one, so rolling a release back does not turn into an outage over a key the previous binary has never heard of. Nothing in this section changes that — the gateway and the control plane still accept and ignore every key they do not know.

The price of that promise is silence. connect_sesc = 3 under [timeouts] is not an error and not a log line; the default applies and the operator is left debugging a timeout they believe they configured. A key that names a credential is worse: a misspelled api_key_env produces a provider with no credential at all, and the first symptom is a 401 from the upstream.

So rolter check --config rolter.toml reports them, with the path to each and, where one is close enough to be a plausible typo, the key it probably meant:

warn   unrecognised config key providers[0].api_key_evn
       `providers[0].api_key_evn` is not a key rolter reads. It is ignored rather
       than rejected, so the setting it looks like it configures is silently at
       its default. Did you mean `api_key_env`?

Nested tables and arrays of tables are covered, so the path is the full one — routes[0].targets[0].wieght, not wieght.

These are warnings, always. The file is still valid and the process will still start, so an unknown key may never block a boot; that would be deny_unknown_fields by another name. --strict promotes them to failures, which is what a CI job that lints a config in review wants:

rolter check --config rolter.toml --strict

The detection lives in crates/rolter-core/src/config_lint.rs and works by deserializing the document a second time through serde_ignored, which reports every key the real Deserialize impls dropped. It is deliberately not a list of valid keys: a hand-maintained list drifts within a release, and it drifts in the direction that reports a brand-new key as a typo. The suggestion comes from a plain Levenshtein distance against the sibling keys the type actually has.

The same keys are reported at startup (#1434)

rolter check is opt-in, and an operator who mistypes a key does not run it first — they restart the process and read the log. So every path that loads a rolter.toml logs the same sentence as a WARN line before it starts:

  • rolter gateway --config rolter.toml
  • rolter control --config rolter.toml
  • rolter easy-up
  • rolter-seed --import rolter.toml, before the first row is written
WARN rolter_core::config_lint: unrecognised config key: `providers[0].api_key_evn` is not a
     key rolter reads. It is ignored rather than rejected, so the setting it looks like it
     configures is silently at its default. Did you mean `api_key_env`?
     config=rolter.toml key=providers[0].api_key_evn

The wiring hangs off GatewayConfig::load rather than off each binary, so a new load path cannot be added without it. It stays non-fatal everywhere: the warning never stops a process from starting and never aborts an import. Only rolter check --strict turns an unknown key into a non-zero exit, and that is a lint run, not a boot.

Each file is reported once per process. rolter easy-up seeds the database, starts the control plane and starts the gateway from one rolter.toml, and one typo printed three times reads like three problems.

The config the gateway pulls from the control plane’s /internal/snapshot needs none of this: that document is JSON assembled from database rows, every column of which is already constrained by the schema and by the CRUD API that wrote it. There is no hand-typed key in it to misspell.

Generating the configuration (rolter init)

rolter check failing on a missing ROLTER_KEK and suggesting openssl rand -hex 32 still leaves an operator to invent the rest of the file around it. rolter init writes it.

rolter init                                  # rolter.toml + .env, production profile
rolter init --profile local                  # the loose defaults, for development
rolter init --print                          # stdout only, for piping into a secret manager
rolter init --env-only --database-url ...    # just the environment

It generates five distinct secrets, each 256 bits from the system CSPRNG:

  • ROLTER_KEK, the key-encryption key for provider credentials at rest
  • ROLTER_ADMIN_TOKEN, the management API’s bearer token
  • ROLTER_INTERNAL_TOKEN, shared between the control plane and its gateways
  • ROLTER_KEY_PEPPER, mixed into virtual-key digests
  • ROLTER_SESSION_PEPPER, mixed into the digests of sign-in sessions, invitation links, SCIM tokens and MFA recovery codes

They are distinct by construction and a test asserts it: reusing one value in two roles would mean a leaked admin token also decrypts every stored credential.

Nothing here is interactive. A generator that prompts cannot run in the Dockerfile, the Helm hook or the CI job where a production deployment is actually assembled, and an operator who has to answer six questions will hand-write a .env instead. Flags carry the choices and the defaults are the secure ones.

It will not overwrite without --force, and the refusal says why: regenerating ROLTER_KEK strands every credential encrypted under the old one. That failure is total, delayed, and looks like data corruption.

On unix the generated files are created 0600 at creation time rather than chmod’ed afterwards — a .env holding the KEK is a credential, and fixing the mode after the fact leaves a window.

What the profiles differ on

--profile production (default)--profile local
ROLTER_CONTROL_HOST127.0.0.1 — the management plane is an admin surface0.0.0.0
[server] require_authtrue — revoking the last key closes the data planeunset, defers to the deployment shape
Passes rolter checkyes, cleanlyno, and deliberately so

The data plane binds 0.0.0.0 under both: it is the public surface.

In a container

Run it as a pre-start step so the container never reaches a serving state while misconfigured:

CMD ["sh", "-c", "rolter check --strict && rolter control"]

Docker Compose

The bundled docker/docker-compose.yml is the local stack and is deliberately loose — example postgres credentials, no KEK, management plane wide open. Gating local bring-up on production rules would break the one path that is meant to have no friction, so the check lives behind a profile and never runs on docker compose up:

docker compose -f docker/docker-compose.yml --profile preflight \
               run --rm --env-file /path/to/production.env preflight

Kubernetes and Helm

The chart ships the init container already, enabled by default:

preflight:
  enabled: true
  strict: true   # a warning means the deployment works but is not what you meant
  connect: false # opt in to also probe the datastores

It renders with the exact env block the workload container gets — both come from one shared template, because three deployment paths that each re-implement “is this configured safely” will drift, and the one that drifts is the one nobody notices until a credential was stored unencrypted. The helm chart steps of the static checks CI job lint the chart and render every branch of that template. Each render is parsed with a strict YAML loader that rejects a duplicate mapping key (#1090). To run that parse on the default render locally after a chart change:

helm template ci charts/rolter | uv run --script scripts/check-rendered-manifests.py

If you are writing your own manifests, prefer an init container so the failure is visible as a distinct pod status rather than a crash-looping main container:

initContainers:
  - name: preflight
    image: ghcr.io/rolter-ai/rolter:latest
    command: ["/usr/local/bin/rolter", "check", "--strict"]
    envFrom:
      - secretRef:
          name: rolter-secrets

Custom CA bundles

Rolter can add private CA certificates to the normal public-root trust store for outbound HTTPS connections to upstream providers. Certificate-chain and hostname verification remain enabled; this feature does not affect inbound TLS or configure mTLS client certificates.

Minimal air-gapped configuration

Mount a PEM file containing one or more CA certificates, then use either the environment variable:

ROLTER_CA_BUNDLE=/etc/rolter/ca/private-root.pem rolter-gateway --config /app/rolter.toml

or the matching global TOML field:

[tls]
ca_bundles = ["/etc/rolter/ca/root.pem", "/etc/rolter/ca/intermediate.pem"]

[[providers]]
name = "private-vllm"
kind = "openai_compatible"
api_base = "https://llm.internal.example"

ROLTER_CA_BUNDLE replaces the global TOML list. A provider can replace the global selection independently:

[[providers]]
name = "isolated-cluster"
kind = "openai_compatible"
api_base = "https://llm.cluster.internal"
ca_bundles = ["/etc/rolter/ca/cluster-root.pem"]

Other providers keep using the global private roots plus the built-in public roots. Set a provider’s ca_bundles = [] to use public roots only.

Docker Compose

Mount the bundle read-only and pass its in-container path:

services:
  gateway:
    environment:
      ROLTER_CA_BUNDLE: /etc/rolter/ca/private-root.pem
    volumes:
      - ./pki/private-root.pem:/etc/rolter/ca/private-root.pem:ro

Kubernetes

Store the public CA certificate in a ConfigMap or Secret and mount it read-only:

apiVersion: v1
kind: ConfigMap
metadata:
  name: rolter-upstream-ca
data:
  private-root.pem: |
    -----BEGIN CERTIFICATE-----
    ...
    -----END CERTIFICATE-----
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: rolter-gateway
spec:
  template:
    spec:
      containers:
        - name: gateway
          image: rolter:latest
          env:
            - name: ROLTER_CA_BUNDLE
              value: /etc/rolter/ca/private-root.pem
          volumeMounts:
            - name: upstream-ca
              mountPath: /etc/rolter/ca
              readOnly: true
      volumes:
        - name: upstream-ca
          configMap:
            name: rolter-upstream-ca

Validation and rotation

Startup fails with the bundle path and an actionable error when a configured file is missing, unreadable, contains no certificates, or has malformed PEM. Snapshot updates are rejected under the same conditions.

HTTP clients capture trust roots when their connection pool is created. After replacing a mounted certificate, publish or fetch a new configuration snapshot—even if the path is unchanged—to clear configured pools and rebuild them from the new bundle. With static bootstrap configuration, restart the gateway. Existing in-flight connections finish with their original trust configuration; subsequent connections use the rotated bundle.

Self-hosted Ollama

rolter supports a local or privately hosted Ollama daemon through Ollama’s OpenAI-compatible API. This provider does not require an API key.

Native setup

Install Ollama, start the daemon, and pull a small smoke-test model:

ollama serve
ollama pull qwen2.5:0.5b

Configure the daemon origin, without /v1 (rolter appends endpoint paths):

[[providers]]
name = "ollama-local"
kind = "ollama"
api_base = "http://localhost:11434"

[[routes]]
model = "local-qwen"
strategy = "round_robin"
[[routes.targets]]
provider = "ollama-local"
model = "qwen2.5:0.5b"

Start rolter and exercise model discovery, chat, legacy completions, embeddings, and streaming:

curl http://localhost:4000/v1/models
curl http://localhost:4000/v1/chat/completions \
  -H 'content-type: application/json' \
  -d '{"model":"local-qwen","messages":[{"role":"user","content":"hello"}]}'
curl http://localhost:4000/v1/chat/completions \
  -H 'content-type: application/json' \
  -d '{"model":"local-qwen","stream":true,"messages":[{"role":"user","content":"hello"}]}'
curl http://localhost:4000/v1/completions \
  -H 'content-type: application/json' \
  -d '{"model":"local-qwen","prompt":"hello"}'
curl http://localhost:4000/v1/embeddings \
  -H 'content-type: application/json' \
  -d '{"model":"local-qwen","input":"hello"}'

/v1/models lists rolter’s configured public route names, so the example returns local-qwen; it does not expose unrelated models installed in Ollama.

Docker setup

Containers must address Ollama by its Compose service name:

services:
  ollama:
    image: ollama/ollama:0.9.6
    volumes:
      - ollama-data:/root/.ollama

Use api_base = "http://ollama:11434" in the gateway container’s config. The opt-in smoke suite under integration/ollama/ provides a complete reproducible Compose setup and pulls qwen2.5:0.5b automatically.

Compatibility and known gaps

rolter passes OpenAI request JSON and response bodies through unchanged (apart from the configured model-name rewrite), preserving retry, cooldown, health, logging, error mapping, routing, and SSE semantics. Ollama currently documents chat and legacy completions, streaming, JSON mode (response_format), tools, vision message content, seed, and usage fields. The gateway also passes stream_options through, though Ollama may ignore unsupported options.

Support depends on the installed Ollama release and model: tool calling and vision require capable models, JSON schemas are not guaranteed to be obeyed by every model, and some OpenAI fields are accepted but ignored. Ollama’s OpenAI-compatible embeddings endpoint accepts models with embedding support; for production, route it to a dedicated embedding model. Ollama’s native /api/* endpoints and Ollama Cloud authentication are outside this provider’s scope.

OpenRouter

rolter’s openrouter provider targets OpenRouter’s OpenAI-compatible API while keeping OpenRouter model identifiers and routing controls intact.

Configuration

Create an API key in OpenRouter and expose it only through the environment:

export OPENROUTER_API_KEY='...'
[[providers]]
name = "openrouter"
kind = "openrouter"
api_base = "https://openrouter.ai/api/v1"
api_key_env = "OPENROUTER_API_KEY"

[[routes]]
model = "router-chat"
strategy = "round_robin"
[[routes.targets]]
provider = "openrouter"
model = "anthropic/claude-sonnet-4"

Pointing at something other than openrouter.ai

An openrouter provider’s api_base is pinned to https://openrouter.ai/api/v1. The kind names one operator and the key belongs to that operator, so the pin is what stops an edited base URL from sending a live OpenRouter key to another host.

When the endpoint genuinely is somewhere else — a corporate egress proxy or internal gateway in front of OpenRouter, an endpoint that re-implements the API, or a local fake used to exercise the dialect — opt out explicitly:

[[providers]]
name = "openrouter-via-proxy"
kind = "openrouter"
api_base = "https://llm-egress.corp.example/openrouter/v1"
api_key_env = "OPENROUTER_API_KEY"
allow_custom_api_base = true

The opt-out relaxes the host pin and nothing else: the key must still come from api_key_env. rolter check reports every provider that sets it, and rolter check --strict fails on it, so the redirection stays visible in a review rather than becoming a copied default. See ADR-0029.

Providers stored in the control-plane database cannot opt out yet; the pin applies to them unconditionally.

The public rolter model (router-chat) is rewritten only to the target model override. OpenRouter identifiers such as anthropic/claude-sonnet-4, including their provider prefix and optional variants, are otherwise forwarded verbatim. rolter fallback chooses another configured target after a retryable failure; OpenRouter’s own provider request object then controls routing among upstreams inside the selected OpenRouter target.

For example, this body preserves OpenRouter’s provider ordering and fallback policy:

{
  "model": "router-chat",
  "messages": [{"role": "user", "content": "hello"}],
  "provider": {
    "order": ["Anthropic", "Google"],
    "allow_fallbacks": true,
    "data_collection": "deny"
  }
}

Chat completions, SSE chunks, usage/cost fields, response metadata, and OpenRouter error JSON pass through without normalization. rolter still applies its normal authentication, route policy, retries, cooldowns, health checks, request logging, and routing headers. /v1/models lists configured rolter route aliases rather than exposing every model in OpenRouter’s catalog.

Attribution headers

OpenRouter recommends HTTP-Referer and X-Title for application attribution; they are not required for authentication. rolter omits them by default so it does not disclose deployment identity. Set either explicitly when desired:

export OPENROUTER_HTTP_REFERER='https://example.com'
export OPENROUTER_X_TITLE='Example gateway'

These values are forwarded only by openrouter providers. Never put API keys, user identifiers, or private internal hostnames in attribution headers.

Live smoke

The ignored live test makes a billable request and therefore requires both a credential and an explicitly selected model:

OPENROUTER_API_KEY=... ROLTER_OPENROUTER_LIVE_MODEL=openai/gpt-4.1-mini \
  cargo test -p rolter-gateway --test openrouter live_openrouter_smoke -- --ignored

Docker deployment

Compose (full stack)

docker/docker-compose.yml brings up Postgres, Redis, ClickHouse, and the rolter gateway + control services.

cp .env.example .env            # set OPENAI_API_KEY etc.
docker compose -f docker/docker-compose.yml up -d
docker compose -f docker/docker-compose.yml logs -f gateway
  • Gateway: http://localhost:4000
  • Control + UI: http://localhost:4001
  • Postgres 5432, Redis 6379, ClickHouse 8123/9000

ClickHouse runs with nofile at ${CLICKHOUSE_NOFILE:-262144}. Where the container runtime cannot grant that much (rootless Docker or Podman, sandboxed runners), creating the container fails with error setting rlimit type 7: operation not permitted. Set CLICKHOUSE_NOFILE lower, e.g. 20000, in docker/.env and rerun (#1819). Compose reads that file because the project directory is the first -f file’s directory; a repository-root .env is not read for interpolation unless you pass --env-file .env. The development setup page has the full error text.

DB schemas auto-apply on first start, by two different routes. The Postgres schema is owned by sqlx::migrate!, which rolter-control and rolter-seed both run on startup; Compose deliberately does not mount migrations/ into the container’s docker-entrypoint-initdb.d, because letting Postgres apply those files itself would bypass sqlx’s _sqlx_migrations bookkeeping and make the next startup replay every migration against a populated database (#499). ClickHouse has no such runner, so clickhouse/ is mounted into its initdb directory and is that schema’s only provisioning path.

Image

The multi-stage docker/Dockerfile produces a slim Debian runtime with both binaries and the built UI at /app/ui/dist. Its default command is rolter easy-up, so one image serves the gateway and dashboard with the built-in fake-llm model — no compose file, provider key, or config mount required.

docker build -f docker/Dockerfile -t rolter:dev .
docker run --rm -p 127.0.0.1:4000:4000 -p 127.0.0.1:4001:4001 \
  -e ROLTER_ALLOW_OPEN_MODE=1 rolter:dev

The default command passes --host 0.0.0.0 to easy-up. A published port forwards to the container’s own interface and never to its loopback, so easy-up’s loopback default would answer nothing from the host (#1891). The bind is a flag on that one command rather than an image-wide ROLTER_HOST: ROLTER_HOST outranks [server] host, so as an image default it would silently rebind every rolter-gateway run from the image, whatever its config says.

That bind does not reopen #970. With no ROLTER_ADMIN_TOKEN, easy-up refuses a non-loopback host before it starts anything, and the control plane refuses it again on its own, unless ROLTER_ALLOW_OPEN_MODE=1 acknowledges it. The 127.0.0.1: prefix keeps an acknowledged open container on the developer’s machine. Anything other hosts reach needs an admin token, and a virtual key of its own in place of the bundled sk-rolter-dev, which is public and allows every model. docker/smoke/image-smoke.sh checks all three states through published ports: the blocking image-smoke job in quality.yml runs it on every PR, and the release workflow’s smoke image job runs it against each built architecture.

Then open http://localhost:4001 and verify the data plane with:

curl -s http://localhost:4000/v1/chat/completions \
  -H 'Authorization: Bearer sk-rolter-dev' \
  -H 'Content-Type: application/json' \
  -d '{"model":"fake-llm","messages":[{"role":"user","content":"hello"}]}'

Override the command to run just the gateway or control plane. The gateway binds the [server] host of its config, 0.0.0.0 in the bundled example. The control plane defaults to loopback, so inside a container it needs ROLTER_CONTROL_HOST=0.0.0.0, and with that an admin token:

docker run --rm -p 4000:4000 rolter:dev rolter-gateway --config /app/rolter.toml

export ROLTER_ADMIN_TOKEN=$(openssl rand -hex 32)
echo "$ROLTER_ADMIN_TOKEN"   # the management API and the dashboard ask for it
docker run --rm -p 4001:4001 \
  -e ROLTER_CONTROL_HOST=0.0.0.0 -e ROLTER_ADMIN_TOKEN \
  rolter:dev rolter-control

Published images

Release tags publish an image to GHCR (and, when configured, Docker Hub) under the same repo name and tags. Each release is tagged with its version and latest:

docker pull ghcr.io/<owner>/rolter:latest
docker pull ghcr.io/<owner>/rolter:0.0.4

Publishing is fail-closed and opt-in, mirroring the PyPI flow. The publish-docker job in .github/workflows/release.yml runs only when:

  • repo variable DOCKER_PUBLISH_ENABLED = true, and
  • the verify + external-check gates pass for the tagged commit.

GHCR always publishes via the built-in GITHUB_TOKEN. To also push to Docker Hub, set repo variable DOCKERHUB_IMAGE (e.g. docker.io/acme/rolter) and secrets DOCKERHUB_USERNAME / DOCKERHUB_TOKEN; the same tag set is applied to both registries. (Multi-arch images are a separate roadmap item — releases currently ship linux/amd64.)

Production notes

  • Put the gateway behind TLS (ingress/load balancer); keep the control plane private.
  • Set a strong ROLTER_KEK; provide DB/Redis/ClickHouse URLs via env or a secrets manager.
  • Scale gateway horizontally; all replicas hot-reload config from Redis. ClickHouse and Postgres are shared.
  • Kubernetes deployments are supported through the rolter Helm chart.

Air-gapped

Running fully offline behind an internal mirror (Nexus/Artifactory/Harbor)? See Air-gapped install & operation.

Kubernetes and Helm

The supported chart is in charts/rolter. It deploys separate gateway and control-plane workloads and services, with health probes, hardened pod defaults, resource requests, an optional HPA, disruption budget, and ingress.

helm upgrade --install rolter ./charts/rolter --namespace rolter --create-namespace \
  --set env.databaseUrl='postgres://rolter:secret@postgres.example/rolter' \
  --set env.redisUrl='redis://redis.example:6379'

PostgreSQL, Redis, and ClickHouse are external by design. Supply provider credentials through Kubernetes Secrets using secretEnv; do not place credentials in config.file or Helm values committed to source control. For GitOps-managed configuration, create a ConfigMap containing rolter.toml and set config.existingConfigMap.

The gateway defaults to two replicas. The control plane defaults to one replica; scale it only after validating the database migration and UI-hosting behavior for your deployment. Configure TLS at the ingress or service-mesh boundary.

llama.cpp (llama-server)

Rolter’s llama_cpp provider preset targets the OpenAI-compatible API exposed by llama-server. It needs no API key by default and works with local CPU or GPU GGUF deployments.

Start llama-server

Choose a GGUF whose license permits your intended use and whose quantization fits available RAM/VRAM. Q4_K_M is a practical starting point for local use; smaller quantizations use less memory at the cost of quality.

With a native llama.cpp build:

llama-server -m /models/model.gguf --host 0.0.0.0 --port 8080

Or with the upstream Docker image:

docker run --rm -p 8080:8080 -v "$PWD/models:/models" \
  ghcr.io/ggml-org/llama.cpp:server \
  -m /models/model.gguf --host 0.0.0.0 --port 8080

Configure Rolter

api_base is the server origin, without /v1. model on the target is the model identifier reported by llama-server; the public route can be a stable alias.

[[providers]]
name = "local-llama"
kind = "llama_cpp"
api_base = "http://127.0.0.1:8080"

[[routes]]
model = "local-chat"
strategy = "round_robin"

[[routes.targets]]
provider = "local-llama"
model = "model.gguf"

Rolter forwards /v1/chat/completions and /v1/completions, including SSE, sampling fields, grammar, and OpenAI response_format. /v1/models lists Rolter’s public route aliases. Routing headers, retries, cooldowns, and active health checks behave like other providers; the default health probe calls the upstream /v1/models endpoint.

llama.cpp-native routes such as /completion, /tokenize, /detokenize, and slot/metrics administration are intentionally not exposed by Rolter’s generic OpenAI API. Call llama-server directly for those endpoints.

Smoke test

With llama-server running and the model id from its /v1/models response:

integration/llama-cpp-smoke.sh http://127.0.0.1:8080 model.gguf

The script starts a temporary Rolter gateway, verifies model listing, non-streaming completion, SSE, and routing headers, then cleans up.

Hugging Face Text Embeddings Inference (TEI)

The tei provider targets TEI’s OpenAI-compatible POST /v1/embeddings endpoint. Self-hosted TEI is keyless by default; api_key or api_key_env can add bearer authentication when TEI sits behind an authenticated proxy.

Run TEI

For a reproducible CPU deployment with a small embedding model:

docker run --rm -p 8080:80 -v "$PWD/data:/data" \
  ghcr.io/huggingface/text-embeddings-inference:cpu-1.9 \
  --model-id sentence-transformers/all-MiniLM-L6-v2

On Apple Silicon, install and run the native server:

brew install text-embeddings-inference
text-embeddings-router \
  --model-id sentence-transformers/all-MiniLM-L6-v2 --port 8080

Configure Rolter

Use the server origin as api_base, without /v1:

[[providers]]
name = "tei-local"
kind = "tei"
api_base = "http://127.0.0.1:8080"

[[routes]]
model = "embed-local"
strategy = "round_robin"

[[routes.targets]]
provider = "tei-local"
model = "sentence-transformers/all-MiniLM-L6-v2"

Rolter preserves OpenAI string, string-array, token-array, and token-array-batch inputs, plus encoding_format, dimensions, user, embedding vectors, usage, and upstream error JSON. Normal routing headers, retries, cooldowns, logging, and health behavior apply. The default active probe uses TEI’s /health route.

Only /v1/embeddings is part of this adapter. TEI-native /embed, /rerank, /embed_sparse, /predict, /tokenize, /health, and /metrics are not exposed through Rolter’s generic OpenAI surface. Call TEI directly for them.

Smoke test

The opt-in Compose test starts TEI, downloads the small model, starts Rolter, and verifies batch embeddings, optional fields, usage, and routing headers:

integration/tei/run.sh

Air-gapped installation & operation

rolter is designed to run in fully air-gapped environments, with no public internet at build or run time. One outbound call is on by default, the release check, and ROLTER_UPDATE_CHECK=false turns it off. Every other call goes to an endpoint the operator configures, and this page lists each one with the setting that controls it and the code that makes it. It also covers how to install rolter behind an internal mirror and how to verify the deployment offline.

The user-facing copy of this page is docs/user-docs/deployment/air-gapped.mdx. A new outbound path adds its row to both tables.

Runtime egress

The release check is on by default

Two callers ask https://api.github.com/repos/rolter-ai/rolter/releases/latest whether a newer stable release exists:

  • The control plane asks once at boot and every six hours. It feeds GET /api/v1/version and the dashboard footer’s v0.2.0 available hint (#902). Implemented in crates/rolter-control/src/update_check.rs (the checker, the semver order and the endpoint), spawned from crates/rolter-control/src/lib.rs.
  • The rolter launcher starts the same fetch beside every subcommand (update_notice::spawn() in crates/rolter/src/main.rs, before the match), gateway, easy-up, check and init included, and prints a one-line stderr notice (#901). A successful answer is cached in ~/.cache/rolter/update-check.json for 24 hours. Only a success is cached (checked_at is the last successful fetch), so a host with no route to GitHub retries on every invocation. The cache and the notice live in crates/rolter/src/update_notice.rs.

The rolter-control binary runs the control plane’s checker. The standalone rolter-gateway binary has none. The image’s default command is rolter easy-up, which runs both.

One request, a 5-second timeout, a User-Agent: rolter/<version> header and nothing else: no installation id, config or credentials. Failures never log above debug and never delay a command. ROLTER_UPDATE_CHECK set to false, 0, no or off (trimmed, case-insensitive) turns off both callers; unset leaves them on. The endpoint then reports enabled: false and the footer shows the running version alone.

The Helm chart writes control.updateCheck (default true) into rolter.controlEnv, which the control Deployment’s containers share, preflight init container included. rolter.gatewayEnv carries no such variable, so the gateway Deployment’s preflight init container (rolter check, the launcher) still makes the attempt. Until #2382 is fixed, an air-gapped chart install also sets ROLTER_UPDATE_CHECK=false through gateway.extraEnv.

Every outbound path

Each row names what the path reaches, the setting that turns it on or off, its default and the code that makes the call. Every row except the first is idle until its setting is set.

PathWhat it reachesSettingDefaultCode
Release checkapi.github.com, aboveROLTER_UPDATE_CHECK=false (Helm control.updateCheck)Onrolter-control/src/update_check.rs, rolter/src/update_notice.rs
Upstream providersEach provider’s api_base, a model’s advanced base_url, the provider’s egress_proxy / egress_proxies; a semantic cache’s embeddings provider; the control plane’s Test probe of a providerProviders and routesNone (fake-llm is built in)rolter-proxy/src/lib.rs (Forwarder), rolter-proxy/src/egress_resolver.rs, rolter-gateway/src/realtime.rs, rolter-control/src/crud.rs (the test)
PostgresROLTER_DATABASE_URL, from the control plane (rolter check --connect also opens a TCP connection)ROLTER_DATABASE_URLUnset: in-memory storerolter-store/src/postgres/mod.rs, rolter/src/preflight.rs
RedisROLTER_REDIS_URL from both planes: budgets, rate limits, response cache, config pub/sub, the sign-in throttleROLTER_REDIS_URL / --redis-urlUnsetrolter-gateway/src/redis_conn.rs, watcher.rs, rolter-control/src/crud.rs (publish_config_change), login_throttle.rs
ClickHouseRequest logs, payload capture, provider health events, analytics and alert queriesCLICKHOUSE_URL or [logging].clickhouse_url; a gateway with neither inherits the control plane’s at bootUnset: nothing is loggedrolter-gateway/src/logging.rs, health_events.rs, lib.rs, rolter-control/src/analytics.rs
Gateway to control planeSnapshot poll, the log destination at boot, adaptive-routing telemetry (all to the snapshot URL’s origin); the /admin/* proxyROLTER_SNAPSHOT_URL, ROLTER_SNAPSHOT_POLL_SECS (5), ROLTER_ADMIN_URLUnset: static bootstrap configrolter-gateway/src/watcher.rs, adaptive_telemetry.rs, admin_proxy.rs
Control plane to gatewayThe Playground’s /gw/* HTTP and WebSocket callsROLTER_GATEWAY_URLhttp://localhost:4000rolter-control/src/proxy.rs
Alert channelsEach enabled channel’s endpoint, a JSON POST per state change; redirects off, egress policy checkedAlerting → Channels, /api/v1/alert-channelsNo channelsrolter-control/src/alerting.rs (deliver)
OTLP exportThe OTLP receiver, for traces, metrics and logsOTEL_EXPORTER_OTLP_ENDPOINT or the _TRACES_ / _METRICS_ / _LOGS_ variants; ROLTER_TELEMETRY_ENABLED=false blocks every exporterUnset: no exporter is builtrolter-core/src/telemetry.rs
Connector testThe connector’s endpoint: one empty OTLP resourceLogs request per Test, enabled or not. A collector you run does the export to a connectorObservability → ConnectorsNo connectorsrolter-control/src/connectors.rs (deliver_probe)
Dashboard browser tracingThe OTLP/HTTP endpoint injected into the served page, called from each user’s browserROLTER_UI_OTEL_ENDPOINT; ROLTER_TELEMETRY_ENABLED=false drops itUnset: no tracing code is loadedrolter-control/src/lib.rs (ui_runtime), ui/src/lib/telemetry.ts
Active health probesEach provider’s api_base plus [health].path; also_track_via_llm_call sends a one-token completion[health] enabledfalserolter-gateway/src/health.rs
Engine metrics scrapeEach provider’s api_base plus [metrics_scrape].path (/metrics)[metrics_scrape] enabledfalserolter-gateway/src/upstream_metrics.rs
Provider status pagesA provider’s status_page_url, every [health].status_page_interval_secs (60), independent of [health] enabledstatus_page_url on a providerUnsetrolter-gateway/src/status_page.rs
Cache-aware routing signalsA provider’s kv_events.endpoint (a ZeroMQ SUB socket) and lmcache.endpoint (an HTTP poll every refresh_secs)kv_events, lmcache on a providerUnsetrolter-gateway/src/cache_telemetry.rs
MCP serversThe url of each registered server, per call through /mcp/{server}The registered MCP serversNonerolter-gateway/src/mcp_proxy.rs
MCP OAuthThe server’s protected-resource metadata and its authorization server’s metadata at consent start, the token endpoint at consent end, and a refresh sweep every 60 secondsMCP servers set to OAuthNonerolter-control/src/mcp_oauth_discovery.rs, mcp_oauth_flow.rs
Single sign-on<issuer>/.well-known/openid-configuration, the jwks_uri and the token_endpoint, at each sign-inGovernance → Single Sign-OnNo providersrolter-control/src/sso.rs
Guardrail webhookThe webhook URL, one POST per request before it goes upstream[guardrail_webhook] enabled, or an active provider in the registryDisabledrolter-gateway/src/guardrail_webhook.rs, rolter-core/src/guardrail_webhook.rs
PII sanitizer[pii_sanitizer].url and, with restoration on, restore_url[pii_sanitizer] enabledDisabledrolter-gateway/src/pii_sanitizer.rs
Webhook pluginsThe endpoint of each enabled plugin instance, per matching request at its stagePlugins; an instance is live while enabledNo instancesrolter-gateway/src/plugin_dispatch.rs

The egress policy ([egress], see security) covers provider api_base, provider egress proxies, MCP server URLs, alert channels, connectors and MCP OAuth. It does not yet cover SSO issuers, the guardrail webhook, the PII sanitizer, plugin endpoints, status pages, KV-event and LMCache endpoints (#2383).

These make no outbound call:

  • SCIM is inbound only. The IdP calls /scim/v2/* (crates/rolter-control/src/scim.rs); nothing calls the IdP.
  • Invitations send no email. rolter returns the link and the operator passes it on (invitations).
  • Payload capture writes to ClickHouse only, and only when [logging.payload_capture] is enabled.
  • Prometheus metrics are scraped from /metrics; nothing is pushed.
  • Built-in guardrails and the fake-llm model run in the gateway process.

The dashboard and the API reference

  • The interactive API reference at /docs embeds the Scalar JS bundle in the binary and sets withDefaultFonts: false, so it never reaches a CDN or fonts.scalar.com. This is asserted by the docs_page_is_self_contained and scalar_bundle_is_embedded tests in crates/rolter-gateway/src/openapi.rs.
  • The dashboard SPA is served as static assets by the control plane; it loads no third-party scripts, fonts, or styles. The two typefaces are vendored through fontsource (ui/src/lib/fonts.ts) and the icons and link-preview image live in ui/public/. The footer’s repository and bug-report buttons are plain hrefs to github.com that load nothing until clicked.

Install paths through a mirroring proxy

Air-gapped sites usually proxy public registries through an internal mirror (Sonatype Nexus, JFrog Artifactory, Harbor, …). Pick the path that matches how you ship rolter.

Pull through a registry that proxies GHCR/Docker Hub:

docker pull registry.internal.example/rolter/rolter:latest

Or transfer a fully offline image with docker save / docker load:

# on a connected host
docker pull ghcr.io/rolter-ai/rolter:latest
docker save ghcr.io/rolter-ai/rolter:latest -o rolter.tar

# copy rolter.tar into the enclave, then
docker load -i rolter.tar

PyPI wheel (uv tool install / pip)

Install through a Nexus/Artifactory PyPI proxy:

uv tool install rolter --index-url https://nexus.internal.example/repository/pypi/simple
# or
pip install rolter --index-url https://nexus.internal.example/repository/pypi/simple

Or install a downloaded wheel with no index at all:

uv tool install ./rolter-<version>-py3-none-any.whl
# or
pip install --no-index ./rolter-<version>-py3-none-any.whl

crates.io (cargo install)

Point Cargo at a registry mirror or vendored sources via .cargo/config.toml:

# .cargo/config.toml
[source.crates-io]
replace-with = "internal"

[source.internal]
registry = "sparse+https://nexus.internal.example/repository/cargo/"

For a fully offline build, vendor the dependency sources on a connected host and copy them in:

cargo vendor vendor/                 # connected host, writes a [source] snippet
# copy vendor/ into the enclave, add the printed snippet to .cargo/config.toml
cargo build --workspace --offline

Building from source (cargo + bun)

The Rust build follows the crates.io section above. The UI needs an internal npm mirror for bun install:

# ui/bunfig.toml
[install]
registry = "https://nexus.internal.example/repository/npm/"
# alternatively ui/.npmrc
registry=https://nexus.internal.example/repository/npm/
cd ui && bun install && bun run build
cargo build --workspace --release --offline

Operator checklist

Set before you deploy:

  • ROLTER_UPDATE_CHECK=false on the control plane and wherever the rolter launcher runs. In Helm, control.updateCheck: false plus ROLTER_UPDATE_CHECK in gateway.extraEnv (#2382).

Must be reachable inside the enclave:

  • Every configured provider api_base (your internal model servers or a proxied provider endpoint).
  • Postgres, Redis, and ClickHouse hosts — only for the features you enable.
  • The control plane, if the gateway runs with --snapshot-url.
  • Every endpoint you chose to configure from the table above: an alert webhook, an identity provider, an MCP server, a collector.

Must NOT be required:

  • Public package registries at run time (only at install/build time, through the mirror).
  • CDNs (cdn.jsdelivr.net, fonts.scalar.com, npm/unpkg) — rolter references none.
  • api.github.com — with ROLTER_UPDATE_CHECK=false the release check is not attempted.
  • Telemetry endpoints — unless you deliberately set OTEL_* to an internal collector.
  • Provider status pages — leave status_page_url unset.

Offline smoke test

Verify a running gateway with zero external providers using the built-in fake-llm model (deterministic, no upstream or secrets needed):

ROLTER_UPDATE_CHECK=false rolter gateway --port 4000 &
curl -s http://localhost:4000/v1/chat/completions \
  -H 'content-type: application/json' \
  -d '{"model":"fake-llm","messages":[{"role":"user","content":"hello"}]}'

A 200 with a lorem-ipsum completion confirms the gateway serves traffic with no outbound calls. Open http://localhost:4000/docs and confirm the API reference renders with no network requests leaving the host.

Backup, restore and KEK rotation

The control-plane database holds the things an operator cannot regenerate: provider credentials sealed under the KEK, virtual keys, routes, budgets, RBAC assignments and the audit log. This page is the runbook for getting them back.

Backing up Postgres is a solved problem and is not the interesting part. The interesting part is that the KEK is not in the dump, and a restore onto the wrong one fails silently.

What has to be in the backup set

ItemWhere it livesWhat its loss costs
the control-plane databasePostgreseverything below, plus routes, budgets, RBAC and the audit log
ROLTER_KEKyour secret manager, not the databaseevery sealed secret in that database, permanently
ROLTER_KEY_PEPPERyour secret managerevery virtual key hash stops matching; keys must be reissued
ROLTER_SESSION_PEPPERyour secret managereveryone is signed out; open invitations, SCIM tokens and MFA recovery codes stop matching and must be reissued
the gateway’s rolter.toml, if it has oneconfig managementreconstructible, but not from the dump

A backup of the database alone is not a backup. The KEK is stored separately by design — that is the whole point of encrypting at rest — which means the one thing a pg_dump cannot carry is the one thing the restore needs most.

Backup

pg_dump "$ROLTER_DATABASE_URL" --format=custom --file rolter-$(date +%F).dump

--format=custom is worth the flag: it compresses, and it lets a restore be parallelised and selective. Nothing in the schema requires a special dump option; there are no large objects and no extensions beyond pgcrypto.

Verify the KEK is in your secret manager under a name that ties it to this database, not to “production” in general. Two deployments with two KEKs and one label is the most common way to restore onto the wrong key.

Restore

Restore into a database migrated from scratch rather than over a live one, so a restore cannot resurrect a schema the binary no longer expects:

createdb rolter_restored
pg_restore --dbname "$RESTORED_URL" --no-owner --no-privileges rolter-2026-09-02.dump

Then, before starting the control plane, prove the KEK matches:

ROLTER_DATABASE_URL=$RESTORED_URL ROLTER_KEK=... rolter kek verify
ROLTER_KEK opens all 12 sampled secrets across 3 columns

rolter check --connect runs the same audit as part of pre-boot validation, so a deployment that gates on rolter check gets this for free.

When the KEK does not match

error  ROLTER_KEK does not match the one this store was sealed with
       ROLTER_KEK does not open secrets already in this database. This is what a
       restore onto the wrong KEK looks like: the dump carried the ciphertext,
       the KEK was not in the dump, and nothing else fails until an upstream
       call does.
       - provider_keys.ciphertext holds upstream provider credentials — 3 of 3
         sampled rows could not be decrypted

There is no recovery path that does not involve the original KEK. AES-256-GCM is doing exactly what it was chosen to do. Your options, in order:

  1. Find the original KEK. Check the secret manager version history, the deployment that wrote the dump, and any sealed-secret manifest in the cluster.
  2. Re-enter the credentials. The rest of the database — routes, keys, budgets, RBAC, audit log — is unaffected. Clear the unreadable rows and add each provider credential again through the dashboard. Everything else survives.
  3. Never step three. Do not start the control plane against a store it cannot read and hope; that is how an unreadable credential becomes an upstream outage attributed to the provider.

The reason this check exists is that without it the failure has no startup symptom at all: the process boots, the dashboard renders every provider, and the first sign of trouble is a 401 from an upstream hours later.

KEK rotation

Rotation reseals every stored secret from the old KEK to a new one. It is a whole-database operation: a partially rotated store is readable by neither key, so the entire pass runs in one transaction and a single row the old KEK cannot open aborts all of it.

# 1. take a backup first — rotation is not reversible without the old KEK
pg_dump "$ROLTER_DATABASE_URL" --format=custom --file pre-rotation.dump

# 2. stop the writers. The control plane, and any `rolter-seed` run
kubectl scale deployment/rolter-control --replicas 0

# 3. dry run: what would be resealed, and does the old KEK still open it
export ROLTER_KEK_OLD="$(current KEK)"
export ROLTER_KEK="$(openssl rand -hex 32)"
rolter kek rotate

# 4. apply
rolter kek rotate --apply

# 5. publish the new KEK to every process, then start them
kubectl scale deployment/rolter-control --replicas 3

Both KEKs are read from the environment rather than from flags, because a KEK on a command line lands in shell history, in ps output, and in whatever collects a container’s arguments.

Between step 4 and step 5 the fleet is split: a gateway still holding the old value can no longer read a stored credential. Keep the window short, and roll the gateways after the control plane so /internal/snapshot is already serving secrets the new KEK sealed.

Rotation does not touch ROLTER_KEY_PEPPER or ROLTER_SESSION_PEPPER. The digests under them are not reversible, so a pepper change is a reissue, not a rotation: every virtual key for the first, and for the second every session, open invitation, SCIM token and MFA recovery code.

What is audited

rolter kek verify samples every sealed column in the schema:

TableHolds
provider_keysupstream provider credentials
sso_providersSSO client secrets
alert_channelsalert channel webhook secrets
security_settingsthe dashboard’s own upstream credential
observability_connectorsobservability connector credentials
user_totp_factorsTOTP second-factor shared secrets
mcp_serversMCP OAuth client secrets
mcp_serversMCP static bearer tokens and header api keys
mcp_oauth_login_statesin-flight MCP OAuth PKCE verifiers
mcp_oauth_sessionsMCP access and refresh tokens

The inventory lives in crates/rolter-store/src/postgres/kek_audit.rs. A test asks the schema for every *_ciphertext column and fails when one is missing from the list, so a new sealed column cannot quietly escape the audit — but the row in the maintenance matrix is still the thing to read when adding one.

Restore drills

A backup nobody has restored is a hypothesis. The property this page depends on is covered by tests in kek_audit.rs: a seeded store is dumped, restored into a freshly migrated database, and the sealed credential is asserted to still decrypt — then asserted to be caught, not booted into, under a different KEK.

Run the same drill against your own deployment on the cadence your recovery objective implies, and make step “does rolter kek verify pass” part of it.

Multi-region deployment

Rolter gateways are stateless. Run one gateway/control pair per region behind a global load balancer, and keep their operational state in shared managed services:

StateRecommended topologyFailure behaviour
Postgresprimary with cross-region replica / managed HAcontrol writes go to the primary; gateways retain their last atomic snapshot during a control outage
Redis / Valkeymulti-AZ primary with replica or managed global datastoreresponse-cache and rate-limit state are shared; a Redis outage degrades to uncached/local operation
ClickHousereplicated cluster or regional ingest plus central query endpointobservability can lag; request forwarding is never blocked

Use the same bootstrap configuration and ROLTER_KEY_PEPPER in every region. The control plane publishes config-version changes through Redis; gateways also poll /internal/snapshot, so a missed pub/sub event converges without a restart.

Active-active

Deploy at least two regional gateway pools and route client traffic to the closest healthy pool. Point every control plane at the same primary Postgres and every gateway at the same Redis namespace. Configure health checks against /healthz; remove an unhealthy region at the global load balancer, not by withdrawing its routes from the shared database.

For provider failover, put independent regional upstream endpoints in one model route. The existing retry, health, cooldown, and circuit-breaker logic then retries the next target. Keep target names stable across regions so logs and health events remain comparable.

Active-passive

Keep the passive region deployed with the same config and external state endpoints, but give it zero global-LB weight. Promotion is an LB/DNS change after verifying the passive region can reach Postgres, Redis, ClickHouse, and its upstream providers. Do not copy database rows during failover: replication is the source of truth.

Operational checks

  1. Apply ClickHouse migrations in every ingest cluster, including 005_request_payloads.sql when raw payload capture is enabled.
  2. Verify /internal/snapshot returns the same config version from each regional control plane.
  3. Send a fake-llm request through each region and confirm x-rolter-provider/x-rolter-target headers.
  4. Test global-LB withdrawal of one region while a streamed response is in flight; new requests should move regions and existing streams should drain.

Avoid asynchronous multi-primary writes to the same tenancy objects. CRUD changes are serialized through the Postgres primary, while gateways are designed to serve their last good snapshot until the new version arrives.