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
- Overview — system shape, crates, data/control plane split
- Load balancing — strategies and the cache-aware design
- Caching — response cache and KV-cache affinity
- Provider prompt caching — portable Anthropic prompt-cache controls
- Multi-region deployment — active-active/passive state and failover topology
- Config & hot reload — reload-free updates
- Data model — tenancy, keys, pricing, budgets
- RBAC & auth — roles, virtual keys, membership sources
- Invitations — one-time onboarding links for deployments with no IdP
- Single sign-on (OIDC) — optional OIDC login and group→role mapping
- MCP OAuth — MCP servers, consent grants, sealed token sessions
- SCIM provisioning — IdP-driven user lifecycle and provisioning tokens
- Security — secret handling, threat model
- Observability — metrics, tracing, logs
- Performance — hot-path principles and targets
Decisions
API
Development
Deployment
- Zero-config quickstart — start with no keys, add providers at runtime
- Configuration reference
- Custom CA bundles — trust private upstream PKI without disabling TLS verification
- Docker
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—LoadBalancertrait + strategies; pure and unit-tested.rolter-proxy—Forwarderover pooledreqwestclients; 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:
- parses the JSON body enough to read
modelandstream - authenticates the virtual key (when keys are configured) and checks model access
- resolves the route, asks the route’s balancer to
picka target, thenobserves it (cache-aware learning) - forwards to the upstream provider with the right auth header, rewriting the model id
- 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 intobuild(). - New provider protocol: extend
ProviderKindandrolter-proxytranslation. - New storage backend: implement
rolter-storetraits 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 samex-session-idto 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:
| Scorer | Weight |
|---|---|
| adapter residency | 1.0 |
| prefix affinity | 0.5 |
| in-flight load | 0.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.
| Scorer | Weight |
|---|---|
| predicted latency | 1.0 |
| in-flight load | 0.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 case | Strategy |
|---|---|
| Homogeneous pool, stateless | round_robin / random |
| Variable request durations | power_of_two |
| Multi-turn chat, sticky session | consistent_hash |
| Shared system prompts / few-shot / RAG | cache_aware |
| Blend cache + load + weight signals | pipeline |
| vLLM fleet with KV event publishing | precise_cache_aware |
| LMCache fleet with occupancy controller | lmcache_aware |
| Mixed-price providers, minimize spend | cheapest |
| Heterogeneous pool, minimize latency | fastest |
| Mixed price and latency, let the gateway tune | adaptive |
| Many LoRA adapters over shared base weights | lora_aware |
| Heterogeneous pool, variable prompt sizes, deep queues | predicted_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(default0.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_awareand the pipeline’s prefix scorer read the bounded text.consistent_hashwithout 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:
| Path | Where |
|---|---|
| live non-streaming response, not cached | stream_response’s buffered branch |
| cacheable miss (the response being stored) | the cache-store branch of proxy, after put |
| exact hit and semantic hit | cached_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
userandassistantturns (with an OpenAIname), or thepromptof 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. OpenAIsystem/developermessages (with their position) and the Anthropic top-levelsystemare 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.
| Shape | Semantic lookup |
|---|---|
/v1/chat/completions with text-only system/developer/user/assistant turns | yes |
/v1/messages with text-only user/assistant turns and a text system | yes |
/v1/completions with a single string prompt | yes |
| 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 name | bypassed |
/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
- Bootstrap file (
rolter.toml) — used for first run, local dev, and IaC. Maps torolter_core::GatewayConfig. - 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_versionin 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/snapshotand 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 malformedapi_base, a hosted adapter missing itsapi_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 badmetrics_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/snapshotcarries aproblems: [...]array alongsideversionandconfig, 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/problemsand 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 storedperiodit 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’sproblems, because nothing was dropped: the gateway serves the row, just not as the row says.
GET /api/v1/configis 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.
ArcSwapkeeps 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
- Append a
MigrationtoMIGRATIONSwithfromequal to the currentCURRENT_SCHEMA_VERSIONandtoone higher. - Bump
CURRENT_SCHEMA_VERSION.the_chain_is_contiguous_and_reaches_the_current_versionfails if you forget, rather than the step silently never running. - Add a fixture in the old spelling and assert it parses identically to the new one.
- Keep the step idempotent —
migrating_twice_changes_nothing_the_second_timeruns 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
modeltoroute_targetswith astrategy. - 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 onegroup-slug/modelname. The slug shares the provider slug namespace; a member’sweightandpositionfeed the group’s balancing strategy, and a nullupstream_modelforwards the requested model unchanged. - Labels (
labels) attach a(key, value?)fact to a provider, provider group, route or model.sourceseparates an operator’scustomlabel from anautoone Rolter derived, and is part of the uniqueness constraint so the two never overwrite each other;autorows additionally carryobserved_at/observation.subject_idis text because a model is addressed by name, which rules out a foreign key — threeafter deletetriggers sweep a subject’s labels instead. See labels.md.
Cost & limits
model_prices— USD per million tokens for input/output (+ cached input). Used to computecost_usdper request, written to ClickHouse.budgets— spend caps per scope and period; enforced before forwarding and refreshed from spend aggregates.unpriced_policyis 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).periodis free text with no check constraint. The snapshot loader reads it throughBudgetPeriod::parse, which knows the spellings inBudgetPeriod::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,7dbeing 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, andConfigStore::load_problemsreports it soGET /api/v1/config/problemscan say so. No migration rewrites those rows for the same reason. The dashboard keeps its own copy of the spelling table inui/src/lib/budget-period.ts, which names the window on a card and offers the picker’s options;budget-period.test.tsreadsBudgetPeriod::SPELLINGSout ofconfig.rsand 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_rulesstores the ordered built-in and bounded-regex policy managed by the dashboard. Exactly one ofbuiltinorpatternis present on every row.guardrail_providersstores 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— anenabledkill switch plus optionaldefault_model,default_temperature,default_top_panddefault_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_temperatureanddefault_top_paredouble precision, notreal: anf32default serializes into JSON as0.800000011920929and 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. Carriestrace_id, so a UX event and the gateway request it caused are one join apart.tsis 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 ontrace_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 nots, 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
custom | auto | |
|---|---|---|
| written by | an operator | a rolter subsystem |
| means | whatever the operator needs — eu-only, prod, owner:platform-team | a fact rolter established |
| provenance | none | observed_at and observation |
| editable through the API | yes | no |
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_provenancecheck constraint requires anautorow to carryobserved_atand forbids acustomrow from carrying it at all, and no HTTP handler writesauto. A hand-written label can look like anything except a probed verdict. - An auto label never silently overwrites a custom one.
sourceis 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
| Surface | Subjects | Capability |
|---|---|---|
/api/v1/orgs/{org_id}/labels | providers, provider groups, routes | label — org-scoped, read viewer, write admin |
/api/v1/model-labels | models | model_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:
| Gate | Runs | Refuses with |
|---|---|---|
authorize_model | on the requested model id, before budgets, plugins and rate limits | 403 model_not_allowed |
authorize_route | on the resolved route (named or provider-slug/model), before upstream | 403 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:
- tenancy and visibility (
model_visible_to); refused asmodel_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; thenadvanced.visibility.allowed_team_ids/allowed_key_idsnarrow the route to the teams and keys they list. A request with no key at all is refused by any non-empty visibility list. - route policy — the access-profile policy of the key’s creator
(
KeyMeta::route_permitted, #791); refused asroute_not_allowed. - 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 asprovider_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):
authorize_modelon the recorded model id- the recorded route is resolved as the request path would resolve it (named
route first, then
provider-slug/modeladdressing), andauthorize_routeruns on it - 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:
| row | key minted in the store (has an org) | key from the gateway’s config file (no org) |
|---|---|---|
no tenancy (config file) | admitted | admitted |
tenancy of the key’s own org | admitted | admitted |
tenancy of another org | refused | admitted |
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
404an 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::validaterefuses 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/modelandgroup-slug/modelfrom it.Config::validatedoes 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 holdsedge), and a database route may not be namedfake-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 existingslug/…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 withrequire_authoff) 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 arolter-seed --importnamedfake-llmshadows 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 --importrefuses 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:
| row | key minted in the store (has an org) | key from the gateway’s config file (no org) |
|---|---|---|
no tenancy (config file) | admitted | admitted |
tenancy of the key’s own org | admitted | admitted |
tenancy of another org | refused | admitted |
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
404an 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::validaterefuses 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/modelandgroup-slug/modelfrom it.Config::validatedoes 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 holdsedge), and a database route may not be namedfake-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 existingslug/…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 withrequire_authoff) 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 arolter-seed --importnamedfake-llmshadows 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 --importrefuses 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_atis 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
modelsfrom 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 emptymodelsarray means every model, so a project with no routes is a400rather than a key with the widest possible reach. - Lifetime.
expires_in_dayshas a floor of one day. A playground key livesPLAYGROUND_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:
| value | behaviour on an empty key set |
|---|---|
true | always deny (401) |
false | always 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 concreteresource:actionpairs 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:
| endpoint | a role at the parent | a role only below it |
|---|---|---|
GET /api/v1/orgs | superadmin: every org | the orgs they hold a role anywhere inside |
GET /api/v1/orgs/{org_id}/teams | every team | the teams they hold a role in or inside |
GET /api/v1/teams/{team_id}/projects | every project | the projects they hold a role in |
GET /api/v1/orgs/{org_id}/projects | every project | the projects they read through a team or project role |
GET /api/v1/orgs/{org_id}/users, /memberships | everyone | the people and memberships in the teams and projects they read |
GET /api/v1/orgs/{org_id}/invitations | every invitation | the 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
409rather 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_logwith 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::LocalIdentityProviderverifies aCredential::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::OidcIdentityProviderverifies aCredential::AuthorizationCode, wrapping the existing code-exchange and JWKS id-token verification.- Concrete providers live in
rolter-control(next to thesqlx/reqwestthey need), not inrolter-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
POST /api/v1/orgs/{org_id}/invitations(admin at the target scope) returns the invitation, the token, and a ready-madeaccept_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.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.POST /api/v1/invitations/accept/{token}/acceptwith the invitee’s chosen password creates the account, grants the membership, and returns a live session so they land signed in.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
| Setting | Where | Notes |
|---|---|---|
ROLTER_PUBLIC_URL | env | the 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_PEPPER | env | peppers 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.
Related
- 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
| Deployment | Configuration | Login screen |
|---|---|---|
| Local accounts only | no sso_providers rows | email + password |
| SSO only | a provider, and allow_password_login = false on the org | one “Continue with …” button |
| Both | a provider, and password login left enabled | password 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:
GET /auth/sso/{slug}/startmints astate, anonceand a PKCE verifier, stores them insso_login_states, and redirects to the provider’sauthorization_endpoint.- The provider redirects back to
GET /auth/sso/{slug}/callback. - The callback consumes the state row (
DELETE … RETURNING), so a replayedcode+statepair finds nothing and is refused. States older than ten minutes are treated as absent and swept. - The code is exchanged at the
token_endpointwith the PKCE verifier and the sealed client secret. - The id token is verified against the provider’s JWKS: signature by
kid, issuer, audience (client_id), expiry, and thenoncefrom 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).
HS256andnoneare rejected — a symmetric id token signed with a value the attacker may know is not evidence of anything. - The discovery document’s
issuermust 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, whateverallow_password_loginsays. 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
| Setting | Where | Notes |
|---|---|---|
ROLTER_PUBLIC_URL | env | the 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_KEK | env | required to store or read a client secret; the secret is sealed with AES-256-GCM exactly like provider credentials |
ROLTER_SESSION_PEPPER | env | session 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_charsetcheck constraint in0047_sso_providers.sql, which is the rule itself; validate_slug()insso.rs, whichcreate_providerruns before the insert so a bad slug is a400that 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.
Related
- 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.
CurrentUserandPrincipallook tokens up insessionsandvirtual_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) andPOST /api/v1/auth/mfa/confirm(prove it). Every challenge read names a purpose, so/auth/mfa/verifydoes 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 callissue_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 asauth.login, next toauth.mfa_enabledwithat_sign_in: true. The password step writesafter_lockon 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 itsauth.loginrow 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::confirmarms 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_enrolmentupserts only over a row whoseconfirmed_atis null. Thehas_armed_factorcheck 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
attemptscounter, 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
| Table | Holds |
|---|---|
user_totp_factors | one row per user: the sealed secret, confirmed_at, last_used_step |
user_recovery_codes | hashed single-use codes; used_at marks a spent one |
mfa_challenges | logins in flight; hashed token, purpose (verify/enrol), attempt count, expiry, after_lock |
org_auth_policies.mfa_policy | enforcement, alongside the password/SSO switches |
org_auth_policies.mfa_enforce_after | the 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 andrefresh_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
| caller | grants / sessions visible | may revoke |
|---|---|---|
| superadmin / admin token | every one in the org | any |
| org admin | every one in the org | any |
| org member or viewer | only the ones they own | only their own |
| anyone outside the org | none (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/liston 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_scopescan 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 behttp(s); the transport must be one ofstdio,sse,streamable_http,websocket.PATCHupdates 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 whymcp_tool_groupscarries theexperimentalstability 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 whymcp_settingscarries theexperimentalstability 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. Onlyclient_idis required:authorize_urlandtoken_urlare the fallback for a server that publishes no metadata and must be sent together or not at all,issuerpins the authorization server’s issuer identifier for RFC 9207 validation, anddiscoveryisauto(the default) ormanual. The client secret is sealed with the deployment KEK on write and never read back;PUTwith an empty secret downgrades a confidential client to a public one. The response also reports the RFC 8707resourceevery 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 a302, because the caller is the dashboard overfetchand 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:
- an unauthenticated
GETof the server URL, readingresource_metadataout of theWWW-Authenticatechallenge on a401; /.well-known/oauth-protected-resourcewith the server’s path inserted after the suffix (https://h/.well-known/oauth-protected-resource/mcp, nothttps://h/mcp/.well-known/…— RFC 9728 §3.1 puts it the unusual way round);- 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:
| advertised | iss present | action |
|---|---|---|
true | yes | compare against the recorded issuer |
true | no | reject |
false or absent | yes | compare against the recorded issuer |
false or absent | no | proceed |
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, withhttpallowed 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:
reason | when |
|---|---|
access_denied | the authorization server answered error=access_denied |
authorization_failed | it answered with any other error |
state_invalid | no state, no code, or a state that is unknown, expired or already redeemed |
issuer_mismatch | the RFC 9207 check failed and the code was discarded |
token_exchange_failed | the token endpoint refused the code or could not be reached |
not_configured | no ROLTER_KEK, or no resource or OAuth client resolves for the server |
internal_error | anything 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_kinduses: nothing fornone, a write-only credential forbearer, a header name plus credential forheader, and the OAuth client section below foroauth. It writes throughPUT .../authafter the row is saved, and only when the kind, the header name or a typed credential actually moved. That keeps untouched saves out ofaudit_log, and it keeps a deployment withoutROLTER_KEKable 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 asRESERVED_AUTH_HEADERSbefore sending. Clear stored credential is a confirmed, immediate move tonone, because the shape constraint allows nobearer/headerrow without a credential. Saving a credentialed server asnoneoroauthasks for the same confirmation. AROLTER_KEKrefusal 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 isnull, and a field left as it was is omitted from thePATCH, so absent andnullstay distinct on the wire. The rules live inui/src/lib/mcp-server-auth.tswith unit tests. - The OAuth client section (the
oauthbranch above) has: a discovery-mode picker (auto|manual), client id, a write-only client secret, the authorize/token URL pair, a pinnedissuerand the default scopes (#1415). Only the client id is required underauto, where the pair is an optional fallback;manualrequires the pair, and half a pair is refused in either mode.discoveryandissuerare sent on everyPUT— the handler reads an omitted mode asauto, which used to reset amanualserver on every re-save. Underautoa read-only panel shows the row’soauth_discovered_*columns withoauth_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 underauto, and needs the pair undermanual. The rules live inui/src/lib/mcp-oauth-client.tswith unit tests. It readsGET .../oauth-clientpurely forredirect_uri— the callback is deployment-derived and cannot be worked out from the browser’s origin — and writes throughPUT .../oauth-clientafter 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. ThePUTis skipped entirely when nothing in the section changed, so re-saving a server does not fillaudit_logwithmcp_oauth_client.updateentries nobody made. - MCP Catalog → Connect calls
POST .../oauth/authorizeand 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 eachreason, with a link back to the catalog. On success it posts one message on therolter.mcp-consentBroadcastChannel. The catalog tab that started the flow listens and invalidates itsmcp-grantsandmcp-sessionsqueries, so the card marks Connected without a reload; a browser withoutBroadcastChannelfalls 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}/refreshfor 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. Aclient_idtherefore 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 a400withscimType: invalidFilterrather 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
userNamereturns409withscimType: uniquenessinstead 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
activetoggle is implemented, in both thepath: "active"and bare{"active": false}forms, and with the string"true"/"false"some IdPs send. Any other operation is a400— an IdP that gets a success for an operation nothing applied would believe the change landed. - Deactivation logs the user out. Setting
active: falsestampsdeactivated_atand 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
usersrow itself stays: its memberships and audit trail must outlive any one IdP, and a laterPOSTadopts 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 reasonuserName eqis 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
membersentry 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 a400withscimType: 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/replaceonmembers(array of{"value": id}, array of bare ids, or a single entry),removeonmembersboth with a value list and in the filteredmembers[value eq "…"]path form Okta uses,removeon the whole attribute to empty the group,replaceondisplayName/externalId, and the pathless{"op": "replace", "value": {…}}shape. Anything else is a400. - Idempotence. A replayed create is a
409withscimType: uniqueness. A replayedaddof an existing member, or aPUTre-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
DELETEis a404.
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 duplicatescimrow.
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_envover inlineapi_keyso 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.comanddash.example.com.evil.testdo not inherit an entry fordash.example.com.validate_originalready rejects*at write time. - A denied origin gets a normal response with no CORS headers; the browser
enforces the block.
Vary: Originis set on every answer so a shared cache cannot serve one origin’s response to another. - Preflights are answered by the middleware with
204and never reach the router, since they carry no credentials. authorization,content-type,traceparent,tracestateandx-request-idare 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-idis exposed viaAccess-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.
| Setting | Effect on the gateway |
|---|---|
virtual_key_required | an unauthenticated request is refused even where the gateway holds no keys |
required_headers | a request missing any name/value pair is refused at ingress, before routing and before auth |
auth_bypass_routes | the 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_authin 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/modelsdoes not open/v1/models/gpt-4o, andvalidate_bypass_routealready refuses wildcards and non-/v1paths at write time. The MCP surface passes a literal/mcpintoauthenticaterather than its real path, so no bypass entry can ever name it. /healthzand/readyzare exempt fromrequired_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 whatvalidate_settingsaccepts 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.tsreadssecurity.rsand 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.tsdecides which saves ask first. Only three edits loosen:virtual_key_requiredgoing on to off,dashboard_auth_enabledgoing on to off, and a path added toauth_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.tsreads the fleet after a save. The write bumpsconfig_version(the table’s trigger), each gateway reports the version it runs on its snapshot poll, andGET /api/v1/cluster/nodescompares the two asconverged. 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 token | bind | outcome |
|---|---|---|
| set | any | Closed — RBAC enforced |
| unset | all listeners loopback | OpenLoopback — allowed, warned |
| unset | any non-loopback listener | refuses to start |
| unset | non-loopback + --allow-open-mode | OpenAcknowledged — 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:
| caller | request-log rows | captured bodies | provider health |
|---|---|---|---|
| admin token, superadmin session, open mode | all, including rows logged with no org | all | all |
| a user with memberships or custom roles | rows whose org, team or project any of their roles reaches | where 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 = viewer | unchanged | also where they hold only viewer there | unchanged |
| no credentials, or a bearer that is neither token nor session | 401 | 401 | 401 |
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_roledoes — an org admin who is a viewer on one project reads that project’s rows without its bodies — and raised by custom roles asrbac::custom_base_roledoes. A withheld body comes back empty withpayload_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) andprovider_health:read(viewer) are rows inCAPABILITIES, and the extractor reads its thresholds from them throughcap!, soGET /api/v1/rbac/matrixpublishes the rule the filter applies. The per-project exception isproject_settings:update(admin), stored inprojects.payload_min_role;GET /api/v1/rbac/effectivefolds 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_eventscarries 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_TOKENand 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 addedX-*/Viaheaders, 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, andanthropic-versionfor Anthropic. - Responses back to clients likewise gain no rolter-added headers.
- This is a tested guarantee: golden wire tests in
rolter-proxycapture the raw outbound request head and fail on any unexpected header (seeopenai_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:
RestorationTicketis not printable. ItsDisplayrenders<restoration token redacted>, and the token is reachable only throughtoken_for(&scope), which returnsNoneunless 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::Neveris the default;CallerAuthorizedhonoursx-rolter-pii-restoreonly 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), mirroringguardrails.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_openis the default and is the risk to monitor. It forwards unsanitized content when the service is down.rolter_pii_sanitizer_errors_totalrising underfail_openmeans personal data is reaching providers; deployments where the sanitizer is a compliance control should setfail_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.
| Control | Refused with | Counter | Waived by |
|---|---|---|---|
| output guardrails | guardrail_streaming_unsupported | rolter_guardrail_stream_rejections_total | guardrails.streaming_post_call = "passthrough" |
post_response plugin | plugin_streaming_unsupported | rolter_plugin_stream_rejections_total | the plugin’s failure_mode = "fail_open" |
| PII response leg | pii_streaming_unsupported | rolter_pii_stream_rejections_total | pii_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::resolveby 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-Fornames the client only when--login-trust-forwarded-foris 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.
| Credential | Shape | Held by | Checked by |
|---|---|---|---|
| virtual key | sk-rolter-<48 hex> | client applications | the gateway, on /v1/* |
| provider key | the provider’s own shape | rolter | nothing — it is presented upstream |
| admin token | operator-chosen | operators, automation | the 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 verifycatches 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:
- Find. Bind as a read-only service account and search
base_dnfor the entry matchinguser_filter, with{login}substituted. - 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:
| Situation | Result |
|---|---|
| No such user | NotVerified |
| Wrong password | NotVerified |
User outside base_dn | NotVerified |
| Authenticated, but in no mapped group | NotVerified |
| Directory unreachable or misbehaving | Provider("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://norldaps://; require_tlswith a plainldap://URL — the service-account bind password would cross the network in cleartext;- a
user_filterwithout{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(typicallymemberOf). 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 plainldap://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.
| Endpoint | Question | Failure means |
|---|---|---|
GET /healthz | Is the process alive and its runtime not wedged? | Kubernetes kills and restarts the pod |
GET /readyz | Can 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/snapshottraffic — 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
timeoutSecondsso an exhausted pool answers a clean503rather 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_KEKparses when it is set. A KEK set to an empty value seals nothing and decrypts nothing, so the pod cannot serve credentials. Reported asnot 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:
| Answer | Pill |
|---|---|
200 with the body ok | healthy (green dot, breathing) |
503 with the body draining | degraded: “gateway draining”, amber |
502 with the proxy’s own {"error":{"message":…}} body | down: “gateway unreachable”, red |
| anything else, a network error, or no answer within 10 s | unknown, grey |
| no answer yet | checking, 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
/readyzdown for longer than a failover — the control plane cannot reach its database, or is running against a schema it has not migrated. Page./healthzflapping — the process itself is crashing or wedging. Page./readyzdown 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) androlter_request_ttft_ms(time-to-first-token), each labelled{model=...}with the standard_bucket/_sum/_countseries. 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
metricsfacade + global recorder, which does not fit the lock-freearc-swapdesign where an explicitArc<Metrics>is threaded through the request path. - Passive per-target SLA signal:
rolter_target_requests_total{provider,target,outcome}(a counter,outcome=okfor 2xx elseerror) 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 issum(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 ClickHouseprovider_health_eventstable 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_totalcounts requests whose caller left before the response completed, androlter_inflight_requestsis the live in-flight gauge those requests must return to zero. Abandoned requests are logged with status499and are never retried — see Client disconnects. - Provider queues (#1855):
rolter_provider_queue_depth{provider}androlter_provider_inflight{provider}gauges and therolter_provider_queue_wait_ms{provider}histogram, kept by RAII guards inmetrics.rs. A job carries aQueuedGuardfrom the moment it is built until a worker takes it (picked()observes the wait and hands over anInflightGuard) 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 byregister_labelledinrolter_core::telemetry), and each wait is also recorded into an OTLProlter_provider_queue_wait_mshistogram on the Prometheus boundaries, through a per-providerQueueWaitRecorderwhoseproviderattribute is built once when the provider’s counters are. The Redis consumers’ connection state (#1772) rides the same two families; see Redis connections. Therolter · gateway capacityboard inintegration/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 inerror, and the provider’s tokens and cost, androlter_withheld_responses_totalcounts it.usage_unknown = 1marks any successful row whose upstream reported no usage. See Billed but withheld. - Multi-key providers:
rolter_key_cooldowns_tripped_totalcounts 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 betweenblend(the latency/cost/load blend),exploration(the bounded random share that keeps starved targets sampled) andfallback(the deterministicpipelinestack served while the kill switch is off or the evidence is too thin), androlter_adaptive_routing_engaged{model}is1while the blend is actually routing. Only routes on theadaptivestrategy emit these. A config reload rebuilds the balancer and so resets the counters, which lines up with arolter_config_versionbump — alert onrate(), not the absolute value. A route sitting atengaged 0with all picks onfallbackis 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 atGET /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
/metricsand 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:
[logging].clickhouse_urlin its own config file — an explicit local decision is never overridden.CLICKHOUSE_URLin its environment. Both processes read this one variable, which is what a compose file, a Helm release orrolter initsets.- The value the control plane publishes in
/internal/snapshot, when the gateway is started with--snapshot-url. The control plane puts its ownCLICKHOUSE_URLthere, 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-subscriberwithRUST_LOGfiltering;TraceLayerlogs each HTTP request.- Inbound: accept W3C
traceparent/tracestate(andb3) from clients and continue the trace; honorx-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_idis 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-opentelemetryturns everytracingevent 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 theTraceLayer’s span-maker: it builds the request span and makes the extracted inbound context its parent. A B3-only caller is normalized into an equivalenttraceparentfirst, 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:DefaultMakeSpanbuilds the request span at DEBUG, so under the defaultRUST_LOG=infoit 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.requestspan, 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 fromForwarder::forwarded_header_namesare unaffected; only the trace headers changed hands. -
Log correlation.
RequestLog.trace_idis 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:
| Span | Attributes |
|---|---|
auth | — |
guardrails.pre | redacted, webhook |
route.select | route, strategy, candidates |
cache.lookup | hit, kind (exact / semantic) |
queue.wait | provider |
upstream.request | attempt, 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.
| Span | Attributes |
|---|---|
control.request | http.route, http.request.method, http.response.status_code |
snapshot.build | config_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 | |
|---|---|---|
| backend | Jaeger v2 | SigNoz |
| signals | traces only | traces, metrics, logs |
| containers | 2 | 5, incl. its own ClickHouse + Zookeeper |
| storage | in memory, lost on restart | persistent |
| use it for | reading a waterfall, fast iteration | aggregate 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.
| Metric | Unit | Attributes |
|---|---|---|
rolter_snapshot_build_ms | ms | outcome (ok / not_modified / error) |
rolter_snapshot_payload_bytes | By | outcome |
rolter_control_request_ms | ms | http.route, http.request.method, http.response.status_class |
rolter_db_pool_acquire_ms | ms | outcome (ok / timeout) |
And one counter, for the endpoint an unauthenticated attacker can reach (#1079):
| Metric | Meaning | Attributes |
|---|---|---|
rolter_control_login_attempts | resolved control-plane sign-in attempts | outcome (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):
| Metric | Meaning | Attributes |
|---|---|---|
rolter_control_ingest_failures | UX-event or MCP-log writes that were not stored | stream (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):
| Metric | Meaning |
|---|---|
rolter_db_pool_connections | connections the pool holds open |
rolter_db_pool_idle | how many of those are free right now |
rolter_db_pool_max | the 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.
| Item | Verdict | Why |
|---|---|---|
stage_span! (rolter-core/src/telemetry.rs) | keep | code generation, not a runtime wrapper |
The scalar-metrics list (Metrics::scalars()) | keep | the by-name lookup is on the export path, not the request path |
RequestHistograms::record | keep | one unavoidable allocation; the alternative is the anti-pattern |
GatewayMakeSpan (rolter-gateway/src/trace.rs) | keep | SDK/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
datadogexporter). - 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, default1):LogSink::enqueuedrops a row whose request-id hash bucket is at or above the rate (should_sample_requestincrates/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/metricscount every request. The rate is not stored on the row and no read scales by it: below1the analytics endpoints,/api/v1/me/usageand therequest_volume/spend_velocityalert 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). tsis the instant the request began — the same instantlatency_msis 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 fordate_time_input_format=best_effortso that literal parses intoDateTime64(3)). Addlatency_mstotsfor the completion time. The same rule applies to the matchingrequest_payloadsrow, which copies its request’sts.- Gateways older than #1210 did not write
tsat all and let the column’sdefault 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 rowstsmeans 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-settingsacceptsretention_days(1–3650) andpayload_retention_hours(1–8760) and issues the matchingalter table … modify ttlagainst 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 = trueto write redacted request and response payloads to the separaterequest_payloadstable, which has a seven-day TTL (versus 90 days for request metadata).max_bytesbounds each body;redact_fieldsadds recursively redacted JSON keys before storage. Optionalmodelsandvirtual_key_idsallow-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 W3Ctraceparentor B3 (b3/x-b3-traceid) header is parsed and its trace id stored inrequest_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 theb3/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_payloadfloor (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_boundaccepts one grammar:YYYY-MM-DD, optionally followed byTor a space andhh:mm[:ss[.f{1,9}]], optionally followed byZor±hh:mm. That is RFC 3339 as the dashboard writes it (toISOString()), theYYYY-MM-DD hh:mm:ss.sssform ClickHouse returns for aDateTime64(3)under the defaultdate_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
torz, year0000, 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 outsideDateTime64’s1900-2299range are accepted because ClickHouse clamps them to that range instead. - The analytics, health, MCP-log and self-service modules import
time_bounds::Queryin place of axum’sQuery. It deserializes the same way, then checkssinceanduntilthrough theTimeBoundstrait 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_cursorapplies the same grammar to the cursor’s timestamp half, for both the invocation list and the MCP call log.- A refusal is a
400in the gateway’s OpenAI-style envelope,{"error": {"message", "type": "invalid_request_error", "param", "code"}}, withcodeinvalid_time_bound,invalid_cursor, orinvalid_queryfor 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, andts. tsis the instant the observation was made, stamped by the emitter, not by the batch writer: apassiveevent carries thetsof the request it was derived from, and aprobeorstatus_pageevent carries the moment that poll completed. As withrequest_logs, gateways older than #1210 left this todefault now64(3)and so recorded the flush time, which collapsed a whole sweep onto one millisecond and skewed the uptime/MTTR buckets below.sourcedistinguishes where the observation came from:passive(real traffic completing through the request funnel),probe(active liveness sweeps), and the opt-inllm_call/status_pagesources.outcomeisok/error/timeout;error_kindgives 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 noclickhouse_urlis configured the sink is a no-op. - Counters
rolter_health_events_written_totalandrolter_health_events_dropped_totaltrack 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:
| surface | what it said | what 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 all | 4 failed attempts |
rolter_upstream_errors_total | 0 | 4 |
GET /api/v1/analytics/summary | requests=374 errors=0 | correct — 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_burnandsla_breachedagainst anslatarget (query param, fraction in(0,1], default0.99), andlast_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-okevents bounded byok).GET /api/v1/health/timeline?bucket=hour|day|week|month— bucketed ok/error/timeout counts per provider/target for the failure timeline (default buckethour).
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
providerrow 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
targetrow 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_breachedfrom the uptime rollup: the window’s failure rate is over1 - 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_burnover 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 /healthzon both binaries for liveness probes.GET /readyzon the gateway for readiness. It returns503 drainingonce 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;/healthzstays200because 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
- 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 carrieserror = "client disconnected". - 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.
- 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
| Where | Mechanism |
|---|---|
| Before the response starts | CancelGuard (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 streams | UsageLoggingStream (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 accounting | LoadGuard’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
| Series | Meaning |
|---|---|
rolter_client_disconnects_total | counter 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_requests | gauge 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
- 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
usageobject, or a sanitizer re-serialization changes what the caller sees and nothing else. - A refusal is billed exactly once. When a guardrail or plugin blocks, the
refusal goes through the same
UsageLoggingStream::finalizeas 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. - The row keeps both outcomes.
statusis what the caller got (403),withheld = 1marks the divergence,errornames the policy (guardrail_blocked: <rule>orplugin_blocked: <reason>), and the token and cost columns hold the provider’s figures. - The rejected content is never recorded. The refused body is never handed
to the accounting stream, so it can’t reach
request_payloadseven with payload capture on. The request payload is still captured as usual. - Cache hits stay free. A withheld response-cache hit writes a row with
cache_hit = 1,withheld = 1and zero tokens and cost. Nothing was generated upstream for it. The miss that populated the entry was billed when it happened. - Unknown usage is not zero usage.
usage_unknown = 1marks 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 withoutstream_options.include_usage. - A body that never arrives is still logged (#1775). Both buffered paths
(the policy branch of
stream_responseand the cacheable miss inproxy) read the whole upstream body before answering. When that read fails after a2xxstatus line,body_read_failedinhandlers.rswrites one row:502, the read error,usage_unknown = 1. It goes throughLogSink::log, so the target’s passive health funnel androlter_target_requests_total{outcome="error"}count it, androlter_upstream_errors_totalmoves. It used to return a bare502with no row, as if the request had never happened. Covered bywithheld_usage.rsagainst an upstream that closes a200mid-body.
rolter_withheld_responses_total counts refusals of both kinds, cache hits
included.
How it is enforced
| Where | Mechanism |
|---|---|
handlers::stream_response, buffered branch | parse_usage right after translate_response; a guardrail or plugin block returns through withheld_response |
handlers::proxy, cacheable miss | same, before the output guard; the entry is still stored as the upstream sent it |
handlers::buffered_response | takes 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_response | builds 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_response | a blocked hit logs a zero-cost withheld row |
logging::UsageLoggingStream::finalize | prefers the billed usage over the buffer and sets usage_unknown from Usage::reported |
clickhouse/011_withheld_usage.sql | adds 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
READONLYfrom 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:
| Timeout | Value | What it bounds |
|---|---|---|
| connect timeout | 1 s | the callers that wait on one connection attempt, including one against a blackholed address |
| response timeout | 500 ms | one 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), withretry_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}:1while it holds a live connection,0once an attempt failed or the connection was evicted;rolter_redis_reconnects_total{consumer}androlter_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 whereexceededandcheck_atgive 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_limitsandcacheeach have a test that closes the component’s own connection withCLIENT 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_connputs 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 ≤ rpmestimate < 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 = 1admit 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
tpmwindow 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:
| Order | Check | Refusal |
|---|---|---|
| 1 | authentication, model, route | 401 / 403 / 404, unchanged |
| 2 | budgets | 402 insufficient_quota, the HTTP path’s budget_refusal |
| 3 | unpriced_policy | 402 model_unpriced when the policy resolves to block |
| 4 | the gateway is shutting down | 503, see Shutdown |
| 5 | [realtime] max_connections | 429, the process-local resource guard |
| 6 | rate limits | 429 rate_limit_exceeded with Retry-After |
| 7 | upstream connect with failover | 502 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:
- one
request_logsrow per turn, attributed like an HTTP row: org, team, project, key, business unit, customer, model, provider, target and variant. Itsrequest_idis 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 atrace_id - the window’s cost added to every applicable budget counter, in one write
- the window’s tokens added to every applicable
tpmwindow - 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 because | status | error |
|---|---|---|
| the client closed or went away | 499 | client disconnected |
| the upstream closed the session | 502 | upstream closed the realtime session |
| the upstream connection failed | 502 | upstream realtime connection failed |
max_session_secs or idle time | 408 | realtime session closed by <limit> |
| a budget ran out | 402 | the budget refusal message |
| the gateway shut down | 503 | gateway 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):
- Every relay and meter task is tracked from admission on, including the moment between the upgrade response and the completed handshake.
- When
SIGTERMor Ctrl-C arrives, every session is told to close at once, in parallel with axum’s drain of plain HTTP requests. Each client receives a1001(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. - New upgrades are refused with
503from that moment. - Each meter runs its last flush: rows for finished turns,
usage_unknownrows with status503for responses in flight, budget andtpmwrites. - 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
realtimestill 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_streamover thereqwestbyte stream; rolter does not buffer whole responses. - Connection reuse — pooled
reqwestclients with HTTP/2 keep-alive andtcp_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
modelis small but measurable; consider a fast path / partial parse for very high RPS. - Prefer
bytes::Bytes(ref-counted) overVec<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 balancerpickacross 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.pyagainst 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 intoBreaker::allowsand 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.pyis stdlib-only — no install step, no wheels or binaries to vendor. GuideLLM brings a substantial Python dependency tree;oha/k6are 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_msneeds both driven identically in the same run under the same conditions. Adopting one of them means keepingbench.pyanyway for the delta — two harnesses instead of one. - ITL needs SSE token boundaries.
ohaandk6measure 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
simengine (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:
| Metric | Notes |
|---|---|
ttft_p50/p95_ms | time to first byte |
itl_p50/p95_ms | inter-token latency, streaming only, needs --max-tokens > 1 |
latency_p50/p95/p99_ms | end to end |
requests_per_second | achieved, not offered |
error_rate | non-2xx and transport failures, which is what saturation looks like |
added_latency_p50_ms | rolter 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_rateinstead. - 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
| Option | Pros | Cons |
|---|---|---|
| 1. Native passthrough only | No translation and no loss for a Responses-native upstream | Chat and Anthropic routes do not support the new surface |
| 2. Protocol registry with Responses | One public contract for OpenAI, Chat and Anthropic; transport, metrics and accounting stay shared | Not 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_tokensare counted by the existing cost and rate-limit accounting.
Drawbacks and risks:
background,store,previous_response_idand 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.
Related records
- 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
| Option | Pros | Cons |
|---|---|---|
| 1. Re-selection | No new state | Tenant isolation, provider and credential cannot be guaranteed; unsafe |
| 2. Process-local registry | Adds no network hop and no mandatory infrastructure to the data plane; the synchronous write is available as soon as the response completes | Requires sticky routing between replicas; entries are lost on restart |
| 3. Redis registry | Works across replicas and survives a gateway restart | Redis 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
404with 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.
Related records
- 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
modeltoupstream_modelwhen set (crates/rolter-proxy/src/lib.rs:333,maybe_rewrite_model); target selection and failover happen incrates/rolter-gateway/src/handlers.rs(weighted balancer overentry.route.targets). - Providers are
unique(org_id, name); there is no URL-safeslug.
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_namealias; each deployment carrieslitellm_params.modelwith a provider prefix (openai/gpt-4o) plus its ownapi_base/credentials. Multiple deployments sharing onemodel_nameare load-balanced. The prefix is a routing hint tied to a base_url, so you can pointopenai/...at a Qwen endpoint by swappingbase_url— the prefix is semantically muddy and is not a stable identity. - Envoy AI Gateway — extracts the
modelfield from the request body into anx-ai-eg-modelheader before routing, then applies ordinary header-match rules in anAIGatewayRouteto 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
slugcolumn (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
nameis mutable and onlyunique(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
modelstring; no slash-parsing. - Con: not expressible in stock OpenAI/Anthropic SDK
modelfields, so clients that can only setmodel(the common case) cannot use it; discovery is worse. Useful as a complement to B, not a replacement.
Comparison
| Criterion | A (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 cost | none | slug column + CRUD/UI | none | header plumbing |
| Avoids LiteLLM base_url ambiguity | n/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)
- Precedence: try the whole
modelstring 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. - 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. - Balancing: a
provider-slug/modelrequest 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. /v1/models: list both — existing route ids andprovider-slug/modelids (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 (
idascending viarow_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 toprovider. - The first claimant of a base slug keeps it bare (truncated to 63); each subsequent
collision gets a
-Nsuffix from its row number (vllm,vllm-2,vllm-3, …), the base truncated to 58 chars sobase-Nstill 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
slugis validated against^[a-z0-9][a-z0-9-]{0,62}$; an omitted one is derived fromname(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=trueopt-in on update, which warns that theprovider-slug/modeladdress 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/modelafter reclaim may resolve to different weights/base_url than before.
Decision (14 Jul 2026)
- Adopt Option B, coexisting with named routes.
provider-slug/modelresolves 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/modelrequest 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
-Nde-dup (shipped in migration0014); runtime creation validates and rejects conflicts rather than silently suffixing (409 + suggestion as follow-up); slug changes need the explicitallow_slug_changeopt-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), andvllm-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 (anyrolter_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_modelrewrite covers heterogeneous groups, mirroringroute_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 evenvllm).
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
- store: add immutable
slugto providers — migration,unique(org_id, slug), deterministic reported backfill with-Nde-dup, 409+suggestion on create-conflict, soft-delete-preferred reclaim, CRUD wiring. (relates to ROL-81, see Slug collision handling) - proxy/gateway:
provider-slug/modelparsing + resolution with the precedence rule and provider pinning; interaction withmaybe_rewrite_model/upstream_model; tests. - gateway: extend
/v1/modelsto surfaceprovider-slug/modelids. - ui: model-management surfaces the
provider-slug/modeladdress; inline add-provider flow. (relates to ROL-222) - (optional) proxy:
x-rolter-providerheader selector (Option D). - store/gateway/ui: provider groups —
provider_groupsentity (slug in the unified namespace, strategy, members with weights),group-slug/modelresolution with passthrough + per-member rewrite,/v1/modelsunion listing, group CRUD/UI. (see Addendum: provider groups)
Sources
- LiteLLM proxy config — model_name / litellm_params / load balancing / wildcard
- Envoy AI Gateway — model-name-based routing via
x-ai-eg-model
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
- Keep one proxy and rely on an external proxy load balancer.
- Select a random proxy independently for every attempt.
- 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
- Keep exact caching only.
- Introduce a dedicated vector database and approximate-nearest-neighbour index.
- 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
- Keep only the engine-independent approximate prefix trie.
- Query cache state synchronously during every routing decision.
- 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_awaresubscribes 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_awarepolls a rolter-defined HTTP JSON signal containingoccupancyandcache_available, then scores available targets as1 - 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_awareremains 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:
| Entity | readonly (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:
- 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. - 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.
- 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/defaulttenancy 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’sseed_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 theirreadonlytier, so existing configs and the bootstraprolter.example.tomlkeep working unchanged. New docs steer operators to[models.readonly]/[providers.readonly]. [[provider_groups]](introduced in the ADR-0017 addendum / #572, before this ADR) becomes theprovider_groups.readonlyalias; the config-only groups slice is forward-compatible with this decision.- Slugs remain the stable identity across tiers (ADR-0017): a
defaultprovider or group keeps its slug when it becomes a DB row, so itsprovider-slug/model/group-slug/modeladdress is stable across the config→DB transition.
Consequences
- One mental model and one code path shape (
readonlymerge +defaultseed +ConfigOwnedguard) 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 forproviders.defaultandprovider_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
- 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 adefaultnever collides with a readonly key. - store/control:
providers.defaultseed tier — seed function mirroringseed_default_models; providers seeded this way are DB-owned, not config-owned. - store:
provider_groups+ membership tables, repo, andMergedConfigStorewiring so DB-defined groups reach the gateway snapshot. - control: provider-group CRUD (create/update/delete, slug validation shared
with providers, 409+suggestion on slug conflict) +
provider_groups.defaultseed. - 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::ModelPolicybecomes the single definition of the policy shape, its merge rule, and its allow/deny matching. The control plane’sMergedPolicyis now an alias for it.load_virtual_keysresolves each key’screated_byuser to a merged policy in one batched query for the whole key set, and publishes it onVirtualKeyRecord::access_policy.Nonemeans unrestricted.KeyMeta::model_permittedandKeyMeta::route_permittedgate 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
0060addsbump_config_version()triggers to exactly the four tables that feed this resolution:access_profile_policies,access_profile_assignments,access_profiles, andmemberships.
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/effectivereports 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, andmodel_permittedreduces to the pre-existingmodel_allowedcheck. - 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:
- No new explicit span events.
add_eventandrecord_exceptionare not to be called directly. Nothing calls them today and nothing should start. - #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.
tracingstays. 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: whentracing-opentelemetryroutes events through the Logs API, rolter follows by upgrading, without touching 57 call sites.- Correlation is the acceptance bar. A log-based event is only a replacement
for a span event if it carries
trace_idandspan_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. - 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
tracinginstead of calling OTel by hand. - #809 gains a second justification beyond “logs are missing”: it unblocks #808.
Its
trace_id/span_idcorrelation 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::initinstalls the subscriber inmainbefore 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. TheOTEL_*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
routingprocessor 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
EgressPolicythat providerapi_basevalues 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_ENABLEDis 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
- 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.
- Bash script driving docker-compose — closest to “run fresh rolter”, but bash grows unmaintainable for a table-driven RBAC matrix with JSON assertions.
- 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.pyauto-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
| Option | Pros | Cons |
|---|---|---|
| 1. Implement in rolter | Full control over phase placement; phase-aware scoring composes with existing scorers | Requires 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 upstream | Preserves the ADR-0014 boundary; works today with no code; every engine’s own disaggregation implementation is usable, including future ones | rolter cannot influence phase placement, so it cannot improve on the engine’s own decisions |
| 3. Hybrid | Avoids moving KV through rolter; an engine-specific HTTP extension can coordinate a connector-managed transfer | Still 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.
Related records
- 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 endpointopenai_compatibleinstead 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
- 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.
- An explicit
allow_custom_api_base = trueon 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. - Drop the pin and rely on the
api_key_envrule 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_envrequirement 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 checkreports every provider that sets it as a warning, androlter check --strictfails 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"against127.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_compatibleand 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
- 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.
- 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). - 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-controlgains thescalar_api_referencedependency the gateway already carries. The bundle is embedded, so/docsstill 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
| # | Mechanism | Where | Subsystems |
|---|---|---|---|
| 1 | Deployment-wide DB-backed flag | FeatureFlagsConfig, config.rs:171; applied by apply_feature_flags(), config.rs:201 | response_cache, cache_aware_routing, circuit_breaker, active_health_checks, complexity_routing, guardrails |
| 2 | Per-subsystem enabled: bool in config | the subsystem’s own config struct | cache, health, breaker, guardrails, guardrail webhook, PII sanitizer, prompt templates, payload capture |
| 3 | Option<T> presence | RouteConfig.semantic, config.rs:1868 | semantic cache (per route) |
| 4 | Empty collection | PluginsConfig.instances, GatewayConfig.mcp_servers | plugins, MCP servers |
| 5 | Numeric sentinel | RealtimeConfig (0 = no cap), usage_recording.sample_rate = 0 | realtime caps, request-log rows |
| 6 | Per-object enabled row | provider, route, guardrail rule, MCP server, SSO provider tables | those objects individually |
| 7 | Compile-time cargo feature | rolter-control{ldap,postgres}, rolter-core{otlp}, rolter-store{postgres}, rolter{postgres} | LDAP, postgres store, OTLP export |
| 8 | Implicit infrastructure capability | unavailable_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
| Class | Subsystems | “Off” means |
|---|---|---|
| Request-path filter | PII sanitizer, guardrails, guardrail webhook, plugins, prompt templates | Bypass silently. The request proceeds unmodified. A filter that fails closed when disabled would make the switch an outage. |
| Optimisation | response cache, semantic cache, cache-aware routing, complexity routing, circuit breaker, active health checks | Bypass, fall back to the simple path. Already the behaviour apply_feature_flags() implements for cache-aware routing. |
| Gateway surface | MCP gateway, realtime | 501 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. |
| Enforcement | budgets, rate limits | Bypass, and say so. Disabling enforcement is a security-relevant act, so it is audited on write and reported by rolter check. |
| Recording | usage recording, payload capture, analytics, UI events | Drop at ingest. The hot path must not pay for a disabled recorder. |
| UI-only surface | any screen whose whole subsystem is off | Hide 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
truevia#[serde(default = "default_true")], so an upgrade that adds flags cannot change behaviour. This wants a test assertingFeatureFlagsConfig::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
501choice 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
enabledper subsystem config and nothing else. Simplest, but it is mechanism 2 as it stands, and it cannot express the capability layer — an operator flippingresponse_cacheon 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-flagsalready exists, already carriesunavailable, 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:
| Surface | Who consumes it | Can rolter control its shape? |
|---|---|---|
/v1/* gateway | OpenAI and Anthropic SDKs, any HTTP client | No — it mirrors two vendors |
/api/v1/* control API | the dashboard, and SDKs after #851/#421/#422 | Yes, entirely |
rolter.toml and env vars | operators, Helm values, compose files | Yes |
| the Postgres schema | rolter itself; operators’ backups | Yes |
| eight crates on crates.io | anyone who runs cargo add | Yes |
Two things already exist and constrain the answer rather than being free choices:
docs/dev-docs/development/api-stability.md(#1217) already scopedcargo-semver-checksto 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
experimentalmarker 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
descriptionof every published library crate ends with— internal crate, no stable rust api, which is what crates.io search,cargo addand 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-plzgetssemver_check = falseat the workspace level, so a Rust API diff can never drive rolter’s product version.- The
semver-checksjob stays advisory permanently —continue-on-error, never inci-ok’sneeds:. It is retained as a review signal on the four crates inGUARDED, not as a gate, andapi-stability.mdis 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: Bearerandx-api-key), the model-addressing grammar — route names andprovider-slug/modelfrom ADR-0017 — the shape of rolter’s own error envelope, rolter-specific request and response headers, and the shape ofGET /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: trueon the operation or schema in the control-plane OpenAPI document, and served withDeprecationandSunsetresponse 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 alongsidev1, 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/refactorwhose 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
experimentalmarker. 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 inSUBSYSTEMSand 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-coreat 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-checksjob never becomes a merge gate, andapi-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-checksat 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: truein the OpenAPI document,Deprecation/Sunsetheaders, 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
experimentalmarker 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 mountedGET /api/v1/config/export, #1299 mountedPUT /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 requirement | merge queue | |
|---|---|---|
| Who rebases | every author, by hand, every time anything lands | nobody |
| Gate runs to land n ready PRs | n — one per PR, each invalidated by the next merge | one per batch, bounded by the batch size |
| Behaviour when several land in a row | last-merge-wins thrash; a PR that finishes rebasing is already stale again | entries re-test only when something ahead of them fails |
| Correctness | the gate ran on a tree containing current master at some point before the merge button was pressed | the gate ran on the exact tree that will exist |
| Failure lands on | whoever was slowest | the 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.ymlrun onmaster— shortens the time a break goes unnoticed, does not prevent it, and the existingpushrun 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:
| Event | Runs | Median run | p90 | Median time lost to queueing |
|---|---|---|---|---|
pull_request | 55 | 9.4 min | 39.8 min | 1.5 min |
push to master | 26 | 29.7 min | 43.8 min | 21.5 min (max 53.0) |
workflow_dispatch (release PR) | 7 | 11.8 min | 20.9 min | 5.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.ymlhad median waits for a runner between 86 and 200 s depending on the job, with one wait of 553 s. The four inci.ymlusually got a runner within 20 s. - The
helm chartjob spent about 200 s building the project’s maturin wheel by accident:uv run --with pyyamlin 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.tomlpinsstableand 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 everyquality.ymlcall (#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
| Job | Workflow | Runs when | Blocking | Contains |
|---|---|---|---|---|
static checks | quality.yml | every call | yes | gitleaks (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 lint | quality.yml | every call | yes | fmt, clippy (default and postgres), cargo doc, cargo hack check --each-feature and the cross-crate feature combination. About 220 s warm |
rust build | quality.yml | every call | yes, except semver | package (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 / doctests | quality.yml | every call | yes | unchanged, about 388 s, 30 min job timeout |
image smoke (published ports) | quality.yml | every call | yes | unchanged from #1950, still its own job |
ui, storybook, docs | quality.yml | every call | yes | one 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.yml | pull_request only | no | unchanged llvm-cov, ratchet and lcov artifact |
codeql (rust) | ci.yml | unchanged | yes | same name; on a PR with no Rust change it skips extract and analyze inside the job |
codeql (actions-js-python) | ci.yml | unchanged | yes | one leg for actions, javascript-typescript and python, which were three |
gate-ok | ci.yml | unchanged | yes | unchanged; the job the title-edit fast path looks up by name |
ci-ok | ci.yml | every event, if: always() | required check | needs 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:
- It lists every package
cargo metadatamarks 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 itsverifiedoutput. - If something is pending,
scripts/wait-for-ci-gate.shwaits for theci.ymlpush run on the same sha and requires that run’sci-okjob to have concludedsuccess. It fails closed on API errors, on any other conclusion, and after 90 min. release-plz releaseneedsrelease-gateand runs only whenverifiedis'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-cachestep inquality.yml,ci.ymlandextended.ymlsaves only onrefs/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 onmaster, so it still saves.engine-integration.ymlnever runs on a master push, so it restoresrust build’s cache through a shared key and saves none (#2203). - pr-title and both agent-session-url checks move into
ci-okas steps. The PR-body check reads the live body from the API onpull_requestinstead 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.ymlwaits on the check names inRELEASE_REQUIRED_CHECKS, whose default listsci-okand the four current codeql legs, so that default changes in the same PR as the matrix. The new leg’s name has no comma becauserelease.ymlsplits the list on commas. An admin deletes theRELEASE_REQUIRED_CHECKSrepository variable first; it equals the default today, so deleting it changes nothing. The shared leg runs oneinitand oneanalyzefor 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 chartrenders run the checker withuv 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 overpyproject.toml, so the package step gains amaturin sdistthat checks the PyPI packaging in seconds without compiling. - Outside the gate,
engine-integration.ymlcancels a superseded sim smoke run per ref on pull requests, anddocs.ymluploads 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-okis the only required check, with the same name and job id. Branch protection requires theci-okcontext from the GitHub Actions app,check-release-handoff.pyasserts it, and the release gate finds the job by that name.gate-okkeeps its name, itsneeds: [quality, codeql]and its guard, because the title-edit fast path finds it by name. That fast path letsci-okgo green on a PR title or body edit without re-running the gate, once it has found a successfulgate-okfrom an earlier run on the same head sha. The job stays inci.yml; inquality.ymlthe API would call itquality / 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 theci-okverdict step, so a cancelled run’sci-okfails closed instead of going green with every step skipped. Check steps avoid it because superseded PR runs are cancelled, andalways()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
::errorannotation 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 showsskipped. A skipped step reads as success to its job, the way a job skipped insidequality.ymlread as success to its caller in #1562, whereci-okwent green without reading a single commit message. quality.ymlstays secret-free (#734). The only token in it isgithub.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-checksstays 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:
| Run | Today | After |
|---|---|---|
| PR push | 36 | 11 |
| PR title or body edit | 3 | 1 |
master push, ci.yml | 33 | 10 |
| master push, release-plz | 30 | 3 (5 on a release commit) |
| master push, release-PR dispatch | 35 | 10, only while release:ready |
isolated merge to master (ceiling) | about 98 | 13, or 23 while release:ready |
| master push, measured average | about 57 (about 59 with #1950) | about 11 |
merge_group run (queue off today) | 35 | 10 |
publish-bootstrap verify | 27 | 6 |
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.
| Gate | Measured 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 onci.yml: a workflow skipped by a path filter leaves the requiredci-okpending forever, so a docs-only PR could never merge. - Diff-based skipping of blocking jobs: a skipped job inside
quality.ymlreads as success to its caller, the mechanism behind #1562. With the queue off, one classifier miss lands onmasterunchecked. - A check-run name lookup for the release gate: anything can post a check-run
named
ci-okon amastersha, 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-slimrunners: they draw on the same plan pool, so they change the hardware without freeing a slot. parallel:orbackground: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
openedandreopened: its rules match changed files, so a PR whose later push first touchesui/would never get its area label (#2036). - Folding
image smokeintostatic checks: the simulation, run before therelease:readychange 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)
| Method | Path | Notes |
|---|---|---|
| POST | /v1/chat/completions | OpenAI chat; streaming via "stream": true (SSE) |
| POST | /v1/completions | OpenAI legacy completions |
| POST | /v1/responses | OpenAI Responses; provider-native passthrough, streaming supported |
| GET, DELETE | /v1/responses/{id} | retrieve or delete a tenant-scoped native Responses resource |
| POST | /v1/responses/{id}/cancel | cancel a tenant-scoped native Responses resource |
| GET | /v1/responses/{id}/input_items | list input items for a tenant-scoped native Responses resource |
| POST | /v1/messages | Anthropic Messages; streaming supported |
| POST | /v1/embeddings | OpenAI embeddings; non-streaming |
| POST | /v1/rerank | Cohere/Jina rerank; non-streaming |
| POST | /v1/images/generations | OpenAI image generation; non-streaming |
| POST | /v1/audio/speech | OpenAI text-to-speech; binary audio response |
| POST | /v1/audio/transcriptions | OpenAI speech-to-text; multipart/form-data upload |
| POST | /v1/audio/translations | OpenAI 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/models | lists route names, provider-pinned and group addresses (see Model listing) |
| GET | /openapi.json | OpenAPI 3.1 description of this request surface (self-contained, no external assets) |
| GET | /docs | interactive Scalar API reference (assets embedded in the binary — works air-gapped) |
| GET | / | service-info landing (version + links to docs/openapi/health) |
| GET | /healthz | liveness — process is up, no dependency checks |
| GET | /readyz | readiness — 503 while draining (see Health & readiness) |
| GET | /metrics | Prometheus 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:
| Id | owned_by | Where it comes from |
|---|---|---|
a route name (chat) | rolter | every configured route, plus the built-in fake-llm unless a route the key can use shadows it |
provider-slug/model | the provider’s name | the upstream models the provider’s routes name, plus the catalogue the provider reported to its health probe |
group-slug/model | the group’s name | the 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 unlistedprovider-slug/modelstill 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/modelsis 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 themodelform 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:
| Persona | Role today | Script | Their question |
|---|---|---|---|
| Platform operator / org admin | superadmin, or admin at the org | platform-admin.md | “How do I stand this up, connect our models and hand it to people?” |
| Team lead | admin at a team | team-lead.md | “How do I give my team models, keep them inside a budget, and see the bill?” |
| LLM / ML / software engineer | member at a project | engineer.md | “How do I get a key, call models, and see why my call failed?” |
| Application / service account | a virtual key (no human) | app-service.md | “What happens to my traffic, my limits and my key over time?” |
| Finance / FinOps | viewer at the org (admin to cap) | finops.md | “What did we spend, on what, for whom — and can it stop at a limit?” |
| SecOps | superadmin (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 org | viewer.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.
- Five minutes, no credentials.
rolter easy-up, open the dashboard and send a Playground message to the built-infake-llmmodel. Proves the gateway and the dashboard before a single provider key exists. Add--clickhouse-urlpointing 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) - 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)
- Configuration as code. Keep providers, groups, routes, prices and prompt
templates in a
rolter.toml, apply it withrolter-seed --import(desired state), and read the live state back withrolter config export. What the file cannot carry — keys, budgets, SSO, policy — stays in the dashboard or the API. (platform-admin A3-d) - 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:
| status | meaning |
|---|---|
| verified | passed in a recorded dogfood run (the run log names the date) |
| works | the product supports it and nothing is known to be wrong, but no recorded run has walked it yet |
| partial | it can be done, with a workaround the step spells out; the linked issue removes the workaround |
| gap | the product cannot do this yet; the linked issue is the plan |
| bug | it 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:
| account | role | scope | persona |
|---|---|---|---|
dev@rolter.local | super | deployment | platform operator, DevOps, SecOps |
orgadmin@rolter.local | admin | org default | org admin |
lead@rolter.local | admin | team default | team lead |
engineer@rolter.local | member | project default/default | engineer |
engineer2@rolter.local | member | project research/sandbox | an engineer on a different project |
viewer@rolter.local | viewer | project default/default | viewer |
finops@rolter.local | viewer | org default | FinOps |
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.
- 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. - 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
curlwith the persona’s session (POST /api/v1/auth/login) or the virtual key the script minted. - Check every Expect against the source of truth rather than the screen:
the API’s answer, a
request_logsrow in ClickHouse, anaudit_logrow in Postgres. - 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.
- 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. - The observer runs
just dogfood-watch(liveui_eventsand gateway traffic), keeps SigNoz open onrolter · dashboard UX, and notes every place the person hesitates, backs out, reaches for a refused control (refused_click) or abandons a form (form_abandon). - 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
| signal | where | tells you |
|---|---|---|
| screen views and struggle | ClickHouse ui_events, just dogfood-watch | which screen, how long to interactive, refused clicks, abandons |
| requests | ClickHouse request_logs, LLM Logs | what reached the gateway, the status, the target, the cost |
| who did what | Postgres audit_log, Governance → Audit Logs | every mutation a step claims to make |
| traces | SigNoz, rolter · overview | where a slow step spent its time |
| the gateway’s own view | GET :4000/metrics, Cluster, Adaptive Routing | config version, breaker state, queue, adaptive engagement |
Run log
| date | mode | scripts | result |
|---|---|---|---|
| 2026-09-24 | headless | smoke 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-24 | headless | request-log visibility for every persona | after #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-24 | headless | every 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-24 | headless | all eight scripts, 94 steps, just dogfood-journeys | 59 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.
| issue | what is missing | blocks |
|---|---|---|
| #1846 | team- and project-scoped members cannot select their own scope in the dashboard | E1–E3, T1, V1, V4: every persona below the org |
| #1844 | a route with no visibility list is served to virtual keys of every org | A3, A4 on any multi-org deployment |
| #1845 | a same-named route in a second project freezes config for every gateway | A3, T3 |
| #1847 | a superadmin with no membership cannot open the Playground or mint a personal key | A3.4 |
| #1853 | the Playground asks for models before its key is live, then defaults to a dead route | A3.4, E3.1 |
| #2008 | nothing can check that a provider serves an upstream model name (UI: #2009) | A3.3a |
| #1851 | cache_aware sends every request to one replica and never checks load | A3-d, P2.4, P5 |
| #1850 | a team admin cannot list their own team’s members | T2, T6 |
| #1852 | no second-factor enrolment at sign-in when an org requires one | A1a.4, S2.1 |
| #1854 | sign-in and second-factor audit rows are written with no org, so nothing reads them | S2, S6 |
| #1856 | a flag stored on but unavailable blocks every feature-flag change | D3.3 |
| #1848 | the Dashboard’s setup checklist shows an access error to everyone below admin | E1, V1, F1 |
| #1861 | LLM Logs cannot look a request up by the x-request-id a client received (API: #1863) | E5.2 |
| #1395 | requests refused before routing (401, 402, 403, 404, 429) never reach LLM Logs | E5, P5.4 |
| #1855 | no per-provider queue depth or in-flight metric | D2.4, D3.1 |
| #1815 | the provider queue serialises every provider to one in-flight request | P5, D3 — any concurrent traffic |
| #1822 | self-service account area (epic) | E10, V2, V3, V5 |
| #1823 | display name and bio, editable by the account itself | V2, E10 |
| #1824 | preferences and defaults stored server-side | V3, E10 |
| #1825 | saved filter presets on LLM Logs and the Dashboard | V5, F2 |
| #1831 | members cannot read their own MCP tool-call logs | E6 |
| #1841 | a deprovisioned person’s own virtual keys keep working | T6, S2 |
| #1840 | anonymous /api/v1/config still describes the upstream topology | S1.3 |
| #1133 | a database-backed provider cannot opt out of the hosted host pin | A3-e |
| #1826 | LDAP is implemented but not reachable from sign-in | A1-c |
| #1827 | no SAML single sign-on | A1-b (SAML-only IdPs) |
| #1828 | invitations are links an admin passes on by hand | A1-a, T2 |
| #1829 | alert rules are superadmin-only and deployment-wide | A5, T5, F4 |
| #337 | no warning before a budget blocks | A5, T4, F4 |
| #1830 | no budget per person across their keys | T4, F3, E9 |
| #1833 | project members cannot see the health of their own routes’ providers | E5, T5 |
| #1834 | no operator or security-auditor role short of superadmin | D0, S0 |
| #1835 | payload redaction matches key names, not secrets inside text; no preview | S3 |
| #1085 | one subject’s logs and captured payloads cannot be erased on request | S3.6 |
| #1836 | no scoped control-plane token for automation | P4, D6 |
| #1837 | shared project keys cannot be rotated in place | P3 |
| #1838 | no CSV export of spend or logs | F5 |
| #1816 | a request abandoned while waiting on its upstream is logged with no provider | D4, E5 |
| #1817 | the dogfood fleet’s adaptive route never engages | A3-d (adaptive), D2 |
| #1818 | rolter-seed --import is silent about sections it skips | A3-d |
| #1819 | compose ClickHouse fails where nofile cannot reach 262144 | A0-b on constrained hosts |
| #1890 | the compose stack takes no secrets and its gateway ignores the control plane | A0-b, A0.6 |
| #1939 | a default Helm install fails its own strict preflight | A0-c, A0.6 |
| #1897 | rolter-seed ships in no image or package | A0.8 |
| #1891 | the published image binds loopback, so docker run -p reaches nothing | the quickstart’s docker run |
| #1832 | raw MCP traffic inspection for local servers (mcp-snoop) | E7 |
| #1839 | a 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?
| branch | when | shape |
|---|---|---|
| A0-a | “I want to see it work” — one person, one laptop | rolter easy-up: no database, no keys, loopback only |
| A0-b | one person, one host (trial) | docker compose -f docker/docker-compose.yml up -d: Postgres, Redis, ClickHouse, both planes (open by design, see below) |
| A0-c | production | Helm (charts/rolter), managed Postgres/Redis/ClickHouse, TLS in front, secrets from a manager |
| A0-d | no internet at all | A0-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
| # | step | where | expect | status |
|---|---|---|---|---|
| A0.1 | start everything | rolter easy-up | both planes up; the startup banner prints a ready-to-run curl for fake-llm | works |
| A0.2 | call the built-in model | the printed curl, or the dashboard’s Playground | a deterministic lorem-ipsum completion; no provider or key configured | verified |
| A0.3 | see the request | Observability → LLM Logs (needs ClickHouse) | the row, with model fake-llm and a latency | works |
| A0.4 | notice the deployment is open | the dashboard banner | an open-mode banner: no admin token, so anyone who reaches the port is superadmin | works |
A0-b / A0-c — a real deployment
| # | step | where | expect | status |
|---|---|---|---|---|
| A0.5 | generate the deployment secrets | rolter init (preflight) | admin token, internal token, KEK, key pepper and session pepper written somewhere they outlive a restart | works |
| A0.6 | bring the stack up | docker compose … up -d, or helm install | Postgres, Redis, ClickHouse healthy; migrations applied on control-plane boot | verified (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.7 | check the configuration before trusting it | rolter check | admin token, KEK and database URL present; a warning for a missing key pepper, a missing session pepper or a control plane on every interface | works |
| A0.8 | create the first account | rolter-seed --admin-email … --admin-password … | a superadmin that can sign in; the org, team and project default exist | partial — #1897 (verified from a checkout; not in the image or package) |
| A0.9 | sign in and enrol a second factor | dashboard sign-in, then Settings → My Virtual Keys → Two-factor authentication | TOTP enrolled, ten recovery codes shown once | works |
| A0.10 | confirm the planes agree | Cluster Config | every gateway node live and converged on the current config version | verified |
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
| # | step | where | expect | status |
|---|---|---|---|---|
| A1a.1 | invite a colleague at a scope and role | Governance → Users → Invite user · POST /api/v1/orgs/{org}/invitations | a one-time link, valid for days, naming the role and scope it grants | verified |
| A1a.2 | get the link to them | copy the link into chat or email by hand | the invitee receives it | partial — #1828 |
| A1a.3 | the invitee accepts and chooses a password | the link opens Accept invitation | an account with exactly the invited role; the link is dead once used | verified |
| A1a.4 | require a second factor for the org | Governance → 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.5 | withdraw a link sent to the wrong address | Governance → Users → Pending invitations, the × · DELETE /api/v1/invitations/{id} | the link stops working at once; the address can be invited again | works — #2054 |
| A1a.6 | block a leaver, or remove their account | Governance → Users, deactivate or Edit → Delete · /api/v1/users/{id} | a confirmation names the account; sign-in is blocked and sessions end | works — #2055 |
A1-b — an OIDC identity provider (Okta, Entra ID, Google, Keycloak)
| # | step | where | expect | status |
|---|---|---|---|---|
| A1b.1 | create an OIDC web app in the IdP | the IdP, redirect URI https://<control>/auth/sso/<slug>/callback | a client id and secret | works |
| A1b.2 | register it | Governance → Single Sign-On · POST /api/v1/orgs/{org}/sso-providers | the provider card; the login screen offers Continue with … | works |
| A1b.3 | map IdP groups to roles at scopes | the provider card’s Group mappings | a user in platform-eng becomes admin of team platform, and nothing else | works |
| A1b.4 | decide what an unmapped user gets | the provider’s default role (empty refuses them) | an unmapped user is refused, or lands as the default role at the org | works |
| A1b.5 | turn passwords off for the org | Org sign-in policy: single sign-on only | one button on the login screen; the superadmin keeps a password as the break-glass | works |
| A1b.6 | sign in from a private window as a mapped user | the login screen | the dashboard, scoped to what the mapping granted | works |
| A1b.x | the IdP speaks only SAML | — | — | gap — #1827 |
A1-c — an LDAP / Active Directory directory
| # | step | where | expect | status |
|---|---|---|---|---|
| A1c.1 | point rolter at the directory and bind users | — | — | gap — #1826 |
A1-d — provisioning from the IdP (SCIM), with any of the above
| # | step | where | expect | status |
|---|---|---|---|---|
| A1d.1 | issue an org-scoped provisioning token | Governance → User Provisioning → Issue token | the token, shown once, named after the IdP connector | verified |
| A1d.2 | configure the IdP’s SCIM connector | the IdP | a test user is created in rolter as an org viewer, nothing more | verified |
| A1d.3 | map IdP groups to teams and roles | Group mappings on the same screen | group membership in the IdP becomes a role in rolter on the next sync | works |
| A1d.4 | deprovision the test user in the IdP | the IdP | the account is deactivated, its sessions dropped | verified |
| A1d.5 | their personal keys stop working too | the gateway | a key the leaver minted is refused | bug — #1841 |
A1-e — custom roles and access profiles (optional)
| # | step | where | expect | status |
|---|---|---|---|---|
| A1e.1 | define a role that widens a built-in one by specific grants | Governance → Roles & Permissions | e.g. “viewer, plus create routes in project X” | verified |
| A1e.2 | hand it to people, with an optional model/route policy | Governance → Access Profiles | the grant shows in GET /api/v1/rbac/effective for those people | verified |
A2 — lay out the tenancy
| # | step | where | expect | status |
|---|---|---|---|---|
| A2.1 | create teams and projects that match how money and access flow | the scope switcher’s + · POST /api/v1/orgs/{org}/teams, /teams/{id}/projects | the chain appears in every screen’s scope | verified |
| A2.2 | decide who reads prompts, per project | the gear beside the project → Viewers can read captured payloads | off by default: members and admins read bodies, viewers see rows only (#1820) | verified |
| A2.3 | create business units and customers for attribution | Governance → Business Units, Customers | slugs spend can be recorded against | verified |
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?
| branch | shape | balancing |
|---|---|---|
| A3-a | one hosted provider, one or two models | none — one target per route |
| A3-b | the same model from two providers | failover: retries and cooldowns move traffic off a failing target |
| A3-c | several vendors behind named routes | none or failover, per route |
| A3-d | a self-hosted fleet, many replicas | load-balanced: a strategy per route |
| A3-e | a hosted API reached through a proxy or a re-implementation | as A3-a, with the host pin relaxed |
A3-a — one provider, one route
| # | step | where | expect | status |
|---|---|---|---|---|
| A3.1 | add the provider with its key | Models → Model Providers → + Add provider · POST /api/v1/orgs/{org}/providers | the key is sealed with the KEK and never shown again | verified |
| A3.2 | check it before anything depends on it | the provider’s Test connection | a model list from the upstream, or the reason there is none | verified |
| A3.3 | add a route: public name → provider/model | Model Catalog → + Add model or Routing Rules → + Add route, one sheet · POST /api/v1/projects/{id}/routes | the public name appears in Model Catalog and in /v1/models for keys that may reach it | verified (seed) |
| A3.3a | check the upstream name before saving | Model Catalog → + Add model, the sheet | the provider’s catalogue lists the upstream model, or the reason it could not tell | gap — #2008 (UI: #2009) |
| A3.4 | try it | Playground, the new model | an answer; a row in LLM Logs naming the provider and the cost | bug — #1853; #1847 for a superadmin with no membership |
A3-b — the same model from two providers, with failover
| # | step | where | expect | status |
|---|---|---|---|---|
| A3.5 | give the route two targets | the model sheet’s Targets; Model Catalog lists them per row | both targets listed with weights | verified |
| A3.6 | set the retry budget and cooldown | Settings → Performance Tuning, Circuit Breaker | a transient 5xx on one target is retried on the other before the caller sees it | verified (dogfood deepseek-r1: vllm-spot-01 503s never reached a caller) |
| A3.7 | watch a target trip | Circuit Breaker, Observability → Dashboard | the failing target’s breaker opens, traffic moves, and it is probed back in | works |
A3-c — several vendors behind named routes
| # | step | where | expect | status |
|---|---|---|---|---|
| A3.8 | one provider per vendor, one route per public model name | as A3-a, repeated | clients ask for gpt-4o or claude-sonnet-4 and never learn which vendor answered | verified |
| A3.9 | pin defaults on a route and decide what callers may override | the route’s params and param policy | temperature defaulted, max_tokens capped, overrides allowed or denied | works |
| A3.10 | fill in what callers omit, deployment-wide | Models → Model Settings (superadmin) | a default temperature and fallback model, never overwriting a caller’s value | works |
A3-d — a self-hosted fleet, load-balanced
| # | step | where | expect | status |
|---|---|---|---|---|
| A3.11 | describe the fleet as a file | rolter.toml: providers, provider groups, routes, prices | — | works |
| A3.12 | apply it as desired state | rolter-seed --import rolter.toml | rows created or updated to match the file; the gateway picks them up within its poll interval | verified (4 s) |
| A3.13 | notice what the import did not apply | the import’s output | sections it does not import ([adaptive_routing], [retry], budgets, keys) named | gap — #1818 |
| A3.14 | choose a strategy per route | the model sheet’s Strategy, at creation; changing it later is #2208 | round 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 decide | verified, except cache_aware: it sends every request to one replica — bug #1851 |
| A3.15 | address a whole group | vllm-a100/meta-llama/Llama-3.1-8B-Instruct | the group’s members share the traffic | verified |
| A3.16 | turn adaptive routing on | Adaptive Routing → Settings (superadmin) | deepseek-r1 engages once it has samples | works; dogfood never engages — #1817 |
| A3.17 | take the live state back out as a file | rolter config export --output rolter.toml | an importable file with no credential in it | verified |
A3-e — a hosted API behind a proxy or a local re-implementation
| # | step | where | expect | status |
|---|---|---|---|---|
| A3.18 | point a hosted kind (e.g. openrouter) at another host | allow_custom_api_base = true | the provider reaches the gateway | works in a gateway-only file; gap in the database — #1133 (the dogfood claude-sonnet-4 route 404s) |
A4 — give access to people and applications
| # | step | where | expect | status |
|---|---|---|---|---|
| A4.1 | make engineers members of their projects | A1-a invitations, A1-b mappings or A1-d groups | they can mint their own keys (see engineer E2) | works |
| A4.2 | mint a shared key for an application | Governance → Virtual Keys · POST /api/v1/projects/{id}/virtual-keys | shown once; models and providers allow-lists, expiry, business unit and customer set on the key | verified |
| A4.3 | rotate that key later | — | a new secret on the same key with an overlap window | partial — #1837 (mint a second key, redeploy, delete the first) |
| A4.4 | automate the control plane from CI | — | a token scoped to one project | gap — #1836 (the admin token is superadmin) |
A5 — spend and budgets
| # | step | where | expect | status |
|---|---|---|---|---|
| A5.1 | price every model that costs money | Models → Pricing Overrides (superadmin), or [[model_prices]] | cost_usd on every row; nothing counts as free by accident | works |
| A5.2 | find unpriced traffic | Dashboard (unpriced share), LLM Logs (unpriced flag) | the dogfood fleet shows 12 unpriced models — every fake route | verified |
| A5.3 | cap spend per org, team, project, key, unit or customer | Models → Budgets & Limits → Add budget (admin at that scope) | the next request past the cap gets HTTP 402; counters in Redis | verified |
| A5.4 | cap throughput | Add rate limit | HTTP 429 with Retry-After | verified |
| A5.5 | hear about it before the cap | — | a warning at a threshold | gap — #337 |
| A5.6 | alert on spend velocity | Alerting → Rules, spend_velocity (superadmin, deployment-wide) | a webhook when spend per hour crosses the line | works; 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
| # | step | where | expect | status |
|---|---|---|---|---|
| T1.1 | sign in (SSO or password) | login screen | the dashboard, scoped to team default | bug — #1846 |
| T1.2 | see what the team is already doing | Observability → Dashboard, LLM Logs | the team’s traffic and spend, with captured bodies (admin at the team), and nothing of other teams’ | verified |
| T1.3 | see what the team may call | Models → Model Catalog, Routing Rules | the routes of the team’s projects | bug — #1846 (Routing Rules refuses; the API answers) |
T2 — bring the team in
| # | step | where | expect | status |
|---|---|---|---|---|
| T2.1 | create a project for a new workstream | scope switcher + under the team · POST /api/v1/teams/{id}/projects | the project, owned by the team | verified (API); the switcher’s + needs #1846 |
| T2.2 | invite engineers as members of that project | Governance → Users → Invite user, scope = the project, role = member | a link per person; authorized at the project, so no org admin is needed | works — #2054 adds the scope picker |
| T2.3 | get the links to people | by hand | — | partial — #1828 |
| T2.4 | with SSO or SCIM instead, map the team’s IdP group | done by an org admin once (platform-admin A1-b/A1-d) | new joiners land in the project with no invitation | works |
T3 — give the team models
| # | step | where | expect | status |
|---|---|---|---|---|
| T3.1 | route a public model name to an existing provider | Routing Rules in the team’s project | allowed: routes are project-scoped and the lead is admin above the project | verified |
| T3.2 | add a new provider | Model Providers → + Add provider | refused, naming the Admin role at the org — a request to the platform team | verified (by design) |
| T3.3 | mint a shared key for the team’s service | Governance → Virtual Keys | as platform-admin A4.2 | verified |
T4 — keep it inside a budget
| # | step | where | expect | status |
|---|---|---|---|---|
| T4.1 | cap the team’s monthly spend | Budgets & Limits → Add budget, scope = the team · POST /api/v1/budgets | the team’s requests get HTTP 402 once the month’s spend reaches the cap | verified |
| T4.2 | cap each project’s throughput | Add rate limit, scope = the project | HTTP 429 with Retry-After past the cap | verified |
| T4.3 | give every engineer the same personal allowance | — | “$50 a month each, however many keys they mint” | gap — #1830 |
| T4.4 | raise the budget mid-month | the budget row | budgets cannot be edited: delete and recreate (the counter is keyed by scope, so spend so far still counts) | partial |
| T4.5 | hear before the team hits the cap | — | a warning at 80% | gap — #337 |
T5 — see what happened
| # | step | where | expect | status |
|---|---|---|---|---|
| T5.1 | spend by model and by key over the month | Dashboard, LLM Logs filtered by key | the team’s rows only | verified |
| T5.2 | is an upstream the reason the team’s calls fail? | Circuit Breaker, health rollups | provider health is readable only with an org-level role — the lead sees none | gap — #1833 |
| T5.3 | an alert on the team’s error rate | — | a rule the lead owns, over the team’s traffic | gap — #1829 |
T6 — someone leaves
| # | step | where | expect | status |
|---|---|---|---|---|
| T6.1 | remove their membership | Governance → Users, the × beside the person’s role at the project | they lose the project in the dashboard at once | works — #1850 lists the team’s members, #2053 adds the revoke |
| T6.2 | their personal keys stop working | the gateway | a key they minted for themselves is refused | bug — #1841 |
| T6.3 | shared keys they minted as an admin keep working | the gateway | the team’s services do not go down because a person left | verified |
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
| # | step | where | expect | status |
|---|---|---|---|---|
| E1.1 | sign in | login screen (password or SSO) | the dashboard, scope default/default; admin-only controls disabled with the role named | bug — #1846 |
| E1.2 | find out what models exist | Models → Model Catalog | the project’s routes: gpt-4o, llama-3.1-8b, … with their capabilities | verified |
E2 — get a key
| # | step | where | expect | status |
|---|---|---|---|---|
| E2.1 | mint a personal key | Settings → My Virtual Keys → Generate virtual key · POST /api/v1/me/projects/{id}/virtual-keys | the key once, a name, an expiry; a viewer would be refused | bug — #1846 in the dashboard; verified through the API |
| E2.2 | narrow it to the models the job needs | the mint form’s model list | /v1/models with that key lists only those | verified |
| E2.3 | rotate or delete it | the key card · POST /api/v1/me/virtual-keys/{id}/rotate | a new secret; the old one refused | verified |
E3 — make the first call
| # | step | where | expect | status |
|---|---|---|---|---|
| E3.1 | from the Playground, with no key handling at all | Playground (mints a 30-minute session key for the project) | an answer, streamed | bug — #1846, #1853 |
| E3.2 | from code, OpenAI dialect | OpenAI SDK with base_url=<gateway>/v1, api_key=<key> | the same answer; x-request-id on the response | verified |
| E3.3 | from code, Anthropic dialect | Anthropic SDK with base_url=<gateway>, the key as x-api-key | the same model family through /v1/messages | verified |
| E3.4 | streaming, embeddings | stream: true; /v1/embeddings on text-embedding | SSE chunks; vectors | verified |
E4 — point a coding agent at rolter
| # | step | where | expect | status |
|---|---|---|---|---|
| E4.1 | repoint Claude Code, Codex or Cursor | the third prompt in agent setup | the agent’s own traffic appears in LLM Logs under the engineer’s key | works |
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.
| # | step | where | expect | status |
|---|---|---|---|---|
| E5.1 | read the error the client got | the response body | OpenAI-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.2 | find the request | GET /api/v1/analytics/invocations?request_id=<x-request-id> (#1863); LLM Logs, filtered by status = error and model | the row: status, target, provider, error text, latency, retries, and the captured bodies | partial — #1861 (LLM Logs has no request-id search; the API filter lands with #1863) |
| E5.3 | follow it into a trace | the row’s trace_id, copied from the LLM Logs drawer (#1983), in SigNoz | where the time went: queue, upstream, stream | works |
| E5.4 | check whether the upstream is sick | Circuit Breaker, provider health | the provider behind the route: breaker state, uptime, recent failures | gap — #1833 (health needs an org-level role) |
| E5.5 | a call the client gave up on | LLM Logs, status 499 | which target the client was waiting on | bug — #1816 |
| E5.6 | a call that is slow only under load | concurrent calls to one route | latency stays flat as concurrency rises | bug — #1815 |
E6 — “my MCP tool call failed”
| # | step | where | expect | status |
|---|---|---|---|---|
| E6.1 | connect an MCP server that needs OAuth | MCP Gateway → MCP Catalog, then consent | a session of their own under Auth Sessions | works |
| E6.2 | call a tool through the gateway | the agent, via <gateway>/mcp/{server} | the tool answers | works |
| E6.3 | see the failed tool call | Observability → MCP Logs | the engineer’s own tool-call rows | gap — #1831 (superadmin-only) |
| E6.4 | a session expired | Auth Sessions | the session’s state and a way to renew it | works |
E7 — “my local MCP server misbehaves”
| # | step | where | expect | status |
|---|---|---|---|---|
| E7.1 | see the raw JSON-RPC between the agent and a stdio server | — | every request, response and notification, live — the job tools like mcp-snoop do today | gap — #1832 |
E8 — “try my local model through rolter”
| # | step | where | expect | status |
|---|---|---|---|---|
| E8.1 | point rolter at http://localhost:11434 for personal use | — | a route only the engineer’s keys reach | gap — #1839 (an idea, not a plan) |
| E8.2 | the workaround | rolter easy-up on the workstation | a personal gateway with the same API and logs, pointed at the local model through rolter.toml | works |
E9 — “what have I spent?”
| # | step | where | expect | status |
|---|---|---|---|---|
| E9.1 | usage per key over the last week | Settings → My Virtual Keys (/api/v1/me/usage) | requests and cost per key | verified |
| E9.2 | how much of my allowance is left | — | a personal budget and its remainder | gap — #1830 |
E10 — make the dashboard mine
| # | step | where | expect | status |
|---|---|---|---|---|
| E10.1 | set a display name and a short bio | — | other people see the name instead of an e-mail | gap — #1823 |
| E10.2 | language, default project, Playground model | the language picker; the scope switcher | remembered — but in this browser only | partial — #1824 |
| E10.3 | save “my errors this week” as a view | — | a named filter on LLM Logs | gap — #1825 |
| E10.4 | protect the account with a second factor | Settings → My Virtual Keys → Two-factor authentication | TOTP and recovery codes | verified |
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
| # | step | where | expect | status |
|---|---|---|---|---|
| P1.1 | the owner mints a shared key for the service | Governance → Virtual Keys · POST /api/v1/projects/{id}/virtual-keys | shown once; not tied to the person who minted it, so it survives them leaving | verified |
| P1.2 | narrow it to the models and providers the service uses | the key’s model and provider allow-lists | /v1/models with the key lists exactly those; anything else is refused, 403 model_not_allowed | verified |
| P1.3 | attribute its spend | the key’s business unit and customer | every row the key serves carries both; Governance → Business Units and Customers roll them up | verified |
| P1.4 | give it an expiry where the service is temporary | the key’s expiry | refused after the date, with an authentication error | verified |
P2 — call models
| # | step | where | expect | status |
|---|---|---|---|---|
| P2.1 | the unchanged OpenAI or Anthropic SDK, base URL swapped | <gateway>/v1 | drop-in: no code change beyond the URL and the key | verified |
| P2.2 | correlate its own logs with rolter’s | send x-request-id, or read the one returned | the same id on the request_logs row | verified |
| P2.3 | join its distributed trace | send traceparent | rolter’s spans land in the service’s trace; the upstream receives the context | verified |
| P2.4 | keep a conversation on one replica | send x-session-id | session affinity on consistent_hash and pipeline routes; cache_aware follows the prompt prefix instead | bug — #1851 |
P3 — rotate its key without an outage
| # | step | where | expect | status |
|---|---|---|---|---|
| P3.1 | rotate in place with an overlap window | — | a new secret on the same key, the old one honoured until the rollout ends | gap — #1837 |
| P3.2 | the workaround | mint a second key with the same settings, roll it out, delete the first | no outage — as long as every setting was copied by hand | partial |
P4 — be managed by automation
| # | step | where | expect | status |
|---|---|---|---|---|
| P4.1 | a pipeline mints keys and routes for new customers | — | a control-plane token scoped to one project, audited as the pipeline | gap — #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.
| # | step | where | expect | status |
|---|---|---|---|---|
| P5.1 | an upstream fails transiently | deepseek-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 run | verified |
| P5.2 | the service sends many requests at once | 16 concurrent calls to one route | latency stays near the upstream’s; the queue admits up to its worker count per provider | bug — #1815 (one in flight per provider) |
| P5.3 | it outruns its rate limit | a rate limit on the key | HTTP 429 with Retry-After; the service backs off | verified |
| P5.4 | it exhausts its budget | a budget on the key or its project | HTTP 402; nothing reaches the upstream | verified |
| P5.5 | the queue is full | provider queue at capacity | HTTP 429 queue_full / queue_timeout, depending on the backpressure policy | works |
| P5.6 | the service disconnects mid-request | client timeout | a 499 row, billed tokens kept, naming the target it waited on | bug — #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
| # | step | where | expect | status |
|---|---|---|---|---|
| F1.1 | sign in | login screen | the org’s Dashboard; every mutating control disabled with the role it needs | verified |
| F1.2 | spend over time, by model | Observability → Dashboard | totals, a time series, per-model cost and latency — the org’s traffic only | verified |
| F1.3 | read a request without reading its prompt | LLM Logs → a row | tokens, cost, status; the bodies say they are hidden for the viewer role | verified |
| F1.4 | spend by business unit and customer | Governance → Business Units, Customers · GET /api/v1/analytics/by-attribution | one row per unit or customer | verified |
| F1.5 | what business unit X spent last month | Business 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 Customers | works |
F2 — make the numbers trustworthy
| # | step | where | expect | status |
|---|---|---|---|---|
| F2.1 | find traffic that counted as free | Dashboard unpriced share, LLM Logs unpriced flag | every model with no price row named | verified |
| F2.2 | price it | Models → Pricing Overrides | refused for an org admin: the price catalog is deployment-wide, a superadmin’s | verified (by design); ask the platform team |
| F2.3 | decide what unpriced traffic does to a budget | the budget’s unpriced policy | count it as zero, or refuse it | works |
| F2.4 | keep a standing view of “spend by unit, this month” | — | a saved filter preset | gap — #1825 |
F3 — set limits
| # | step | where | expect | status |
|---|---|---|---|---|
| F3.1 | a monthly cap per business unit and per customer | Budgets & Limits → Add budget (admin at the org) · POST /api/v1/budgets | HTTP 402 for the unit’s or customer’s keys once the cap is reached | verified |
| F3.2 | a cap per person | — | an allowance per engineer across their keys | gap — #1830 |
| F3.3 | change a cap | the budget row | delete and recreate: spend so far is kept, because counters are keyed by scope | partial |
F4 — be told
| # | step | where | expect | status |
|---|---|---|---|---|
| F4.1 | a warning at 80% of a budget | — | a notification before the 402 | gap — #337 |
| F4.2 | an alert when spend velocity jumps | Alerting → Rules spend_velocity | refused below superadmin; deployment-wide only | gap — #1829 |
F5 — month-end
| # | step | where | expect | status |
|---|---|---|---|---|
| F5.1 | export spend by unit and customer for the month | — | a CSV with the window, the currency and unpriced rows flagged | gap — #1838 |
| F5.2 | the workaround | page GET /api/v1/analytics/by-attribution with a session and convert | the same numbers, by script | partial |
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
| # | step | where | expect | status |
|---|---|---|---|---|
| S0.1 | review security posture without being superadmin | — | a read-only security-auditor role: settings, guardrails, audit, no tenant data | gap — #1834 |
S1 — the perimeter
| # | step | where | expect | status |
|---|---|---|---|---|
| S1.1 | confirm nothing runs open | rolter check; the dashboard banner | an 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.2 | confirm an anonymous caller gets nothing | the #1820 sweep, or curl every GET in /openapi.json without credentials | 401 from everything the document does not mark public | verified |
| S1.3 | decide whether the redacted config stays public | GET /api/v1/config without credentials | no secrets, but every upstream api_base, route and group | partial — #1840 |
| S1.4 | restrict browser origins and ingress | Settings → Security; CORS | the dashboard’s origin allowed, nothing else | verified |
| S1.5 | bound outbound requests | the egress policy | a provider, connector or MCP server cannot be pointed at an internal address the policy forbids | verified |
S2 — identity
| # | step | where | expect | status |
|---|---|---|---|---|
| S2.1 | single sign-on only, second factor required | Governance → Single Sign-On → Org sign-in policy | one button; accounts without a factor are refused until they enrol | partial — #1852 |
| S2.2 | throttle password guessing | repeated wrong passwords for one account | HTTP 429 with Retry-After once the account’s allowance is spent; audited | partial — #1854 (throttled; the audit row is unreadable) |
| S2.3 | a leaver loses everything | SCIM deprovision (platform-admin A1-d) | sessions gone at once; personal keys refused at the gateway | bug — #1841 |
| S2.4 | break-glass for a lost second factor | rolter mfa reset --email … --reason … on the host | factor and sessions cleared, the reason audited; not reachable over the API | verified (the audit row: #1854) |
S3 — prove secrets stay out of the logs
| # | step | where | expect | status |
|---|---|---|---|---|
| S3.1 | payload capture is off unless someone chose it | Observability → Logs Settings | off by default; the dogfood profile turns it on deliberately | verified |
| S3.2 | known credential fields never reach storage | payload_capture_redact_fields | the value of a matching JSON key replaced before the write, recursively | verified |
| S3.3 | a key pasted into a prompt never reaches storage | — | a secret inside free text redacted by pattern | gap — #1835 |
| S3.4 | see what the redaction does before trusting it | — | a dry run against a sample body | gap — #1835 |
| S3.5 | who reads the bodies that are stored | sign in as each persona; LLM Logs → a row | members and admins of the project read them; viewers get “hidden for your role” unless the project allows it | verified (#1820) |
| S3.6 | erase one subject’s logs on request | — | a subject’s rows and payloads deleted, audited | gap — #1085 |
S4 — inspection in the request path
| # | step | where | expect | status |
|---|---|---|---|---|
| S4.1 | block a known-bad pattern before it reaches a provider | Guardrails → Rules | the request refused with guardrail_blocked: <rule>; the rejected text never stored | verified |
| S4.2 | hand content to an external de-identification service | the PII sanitizer | the provider sees the substituted text; optionally reversed on the way back | works |
S5 — keys and recovery
| # | step | where | expect | status |
|---|---|---|---|---|
| S5.1 | verify a restored database still opens its secrets | rolter kek verify | every sealed column opens with the current KEK, or the report names what fails | verified |
| S5.2 | rotate the KEK without splitting the fleet | backup and restore | the documented rotation: no node is left unable to open a secret mid-way | works |
S6 — the trail
| # | step | where | expect | status |
|---|---|---|---|---|
| S6.1 | who changed what, when | Governance → Audit Logs (admin at the org) | every mutation in this session’s scripts, with its actor | verified for changes; sign-ins and second factors — #1854 |
| S6.2 | a project was opened to its viewers | the audit log, project.settings.update | the row, naming the value it was set to | verified |
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
| # | step | where | expect | status |
|---|---|---|---|---|
| D0.1 | operate the deployment without superadmin | — | an operator role: cluster, health, runtime policy, alerting, connectors; no tenant data or grants | gap — #1834 |
D1 — stand it up and upgrade it
| # | step | where | expect | status |
|---|---|---|---|---|
| D1.1 | install | platform-admin A0 | both planes, Postgres, Redis, ClickHouse healthy | verified (compose) |
| D1.2 | reproduce an issue on a local copy | just dogfood | the whole stack on one host | partial — #1819 on hosts with a low nofile cap |
| D1.3 | preview an upgrade | upgrading | what the new version will change in the stored configuration, before it runs | works |
| D1.4 | upgrade | new image; migrations run on control-plane boot | the 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 migrate | works |
D2 — know it is healthy
| # | step | where | expect | status |
|---|---|---|---|---|
| D2.1 | liveness and readiness | /healthz, /readyz on both planes | liveness never depends on the database; readiness does | verified |
| D2.2 | every node on the current config | Cluster Config | each gateway live and converged; a lagging one is distinguishable from a dead one | verified |
| D2.3 | the fleet’s own picture | Circuit Breaker, Adaptive Routing → Dashboard, provider health | breaker states, per-target latency, adaptive engagement | works; dogfood adaptive never engages — #1817 |
| D2.4 | metrics and traces in the tools the team already uses | /metrics; Connectors for OTLP export | request rate, latency, errors, queue depth; traces in the team’s backend | partial — #1855 (no per-provider queue depth) |
D3 — tune capacity
| # | step | where | expect | status |
|---|---|---|---|---|
| D3.1 | size the per-provider queue | Settings → Performance Tuning | workers requests in flight per provider; capacity waiting; error/block backpressure | bug — #1815 (one in flight whatever workers says) |
| D3.2 | retries and timeouts | the same screen | a retry budget that absorbs a flaky target without multiplying load | works |
| D3.3 | switch subsystems on and off without a restart | Settings → Feature Flags | the change reaches every replica on the next config poll | bug — #1856 |
D4 — handle an incident
| # | step | where | expect | status |
|---|---|---|---|---|
| D4.1 | a provider goes down | stop a fleet target | its breaker opens, its routes fail over, health shows the outage and the recovery (MTTR) | works |
| D4.2 | clients time out | LLM Logs, status 499 | which target the clients were waiting on | bug — #1816 |
| D4.3 | be paged | Alerting → Channels / Rules: error_rate, p95_latency_ms | a webhook when the error rate or p95 crosses the line | verified (created; firing not exercised) |
| D4.4 | take a node out before touching it | Cluster Config → Drain | the node reports not-ready, finishes in-flight work, receives nothing new | verified (refuses to drain the only live gateway) |
D5 — back up and restore
| # | step | where | expect | status |
|---|---|---|---|---|
| D5.1 | back up the control-plane database | backup and restore | a dump plus the KEK, stored apart | works |
| D5.2 | restore it and prove the secrets still open | rolter kek verify | every sealed column opens | verified |
| D5.3 | keep the configuration as a file too | rolter config export | providers, groups, routes, prices, templates; no credentials | verified |
D6 — automate
| # | step | where | expect | status |
|---|---|---|---|---|
| D6.1 | apply configuration from CI with a least-privilege credential | — | a scoped control-plane token | gap — #1836 (the admin token is superadmin) |
| D6.2 | apply it from a file | rolter-seed --import | desired state for the sections it imports | partial — #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
| # | step | where | expect | status |
|---|---|---|---|---|
| V1.1 | sign in | login screen | the dashboard for project default/default | bug — #1846 |
| V1.2 | traffic, spend and latency | Observability → Dashboard | the project’s numbers only | verified |
| V1.3 | a request in detail | LLM Logs → a row | status, tokens, cost, target; the bodies say they are hidden for the viewer role | verified |
| V1.4 | the bodies too, where the project allows it | a project admin turns on Viewers can read captured payloads | the same row now shows the request and the response | verified (#1820) |
V2 — own the account
| # | step | where | expect | status |
|---|---|---|---|---|
| V2.1 | set a display name and a bio | — | others see the name; the bio says who to ask about what | gap — #1823 |
| V2.2 | enrol a second factor | Settings → My Virtual Keys → Two-factor authentication | TOTP and recovery codes, same as any role | verified (as a member, E10.4) |
V3 — preferences
| # | step | where | expect | status |
|---|---|---|---|---|
| V3.1 | switch the dashboard language | the language picker | the whole dashboard in Russian — remembered in this browser only | partial — #1824 |
| V3.2 | land on the right scope on a new laptop | the scope switcher | the last scope, from the server rather than localStorage | partial — #1824 |
V4 — read the configuration without changing it
| # | step | where | expect | status |
|---|---|---|---|---|
| V4.1 | which models, routes and strategies exist | Model Catalog, Routing Rules | readable; every edit, delete and toggle disabled, naming the role it needs | bug — #1846 (no project in scope, so the screen refuses) |
| V4.2 | which keys exist and what they may reach | Governance → Virtual Keys | names, prefixes, allow-lists; never a secret | bug — #1846 |
| V4.3 | which budgets and limits apply | Budgets & Limits | readable, not editable | bug — #1846 |
| V4.4 | who may do what | Governance → Roles & Permissions | the published matrix, including this session’s analytics, request_payload, provider_health and project_settings rows | bug — #1846 (the API publishes all four rows) |
V5 — keep my own view
| # | step | where | expect | status |
|---|---|---|---|---|
| V5.1 | save “errors on gpt-4o this week” as a named filter | — | one click back to it on any browser | gap — #1825 |
| V5.2 | the saved filter is mine alone | — | nobody else sees it; it never widens what I can read | gap — #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-versionpins (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 prekorbrew 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, anyCargo.tomlorCargo.lock rust-toolchain(.toml),.cargo/,.config/deny.toml,.config/nextest.tomlprek.tomland the twoscripts/prek-rust-*.shscripts- the files a Rust test reads from outside its crate:
rolter.example.toml,docs/dev-docs/development/stability-markers.mdandui/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:
- Create a
live-providersrepository 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. - Add
GEMINI_API_KEYto that environment. - 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:
| Probe | Confirms |
|---|---|
| text turn | turn mapping, system_instruction, generation_config, usage — all documented |
| inline image part | the inferred mime_type/data inline part shape |
| remote image part | the inferred file_uri shape — the other branch, which the inline probe never reaches |
| tool call round trip | function_call out, function_result back, and call_id correlation |
| interaction threading | the id rolter surfaces as the response id is the one Google accepts back |
| every client dialect | Chat Completions, Messages and Responses have separate response translators |
| every client dialect, streaming | the 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 threads | Pool per test | max_connections | Peak client backends | Outcome |
|---|---|---|---|---|
| 1 × 8 | 10 | 100 | 14 | green |
| 6 × 8 | 10 | 100 | 78 | green |
| 4 × 24 | 10 | 100 | 100, the limit | 978 refused connections, 79 of 560 tests failed |
| 4 × 24 | 10 | 300 | 120 | green but one flake of the sweep test (#1910) |
| 4 × 24 | 4 | 300 | 114 | green |
| 1 × 24 | 1 | 300 | 27 | 3 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-pgstarts the server withmax_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_transactionis per slot) also gives the schema drops more room. On a server of your own, sizemax_connectionsthe same way: worktrees × (test threads + 5)TestSchemabuilds 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_CONNECTIONSoverrides 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
nextesttimeout, a laptop losing power — never runsDropat 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 fromROLTER_TEST_DATABASE_URLread directly - the schema comes from
TestSchema, never from a hand-rolledcreate schema - no test in these suites installs a process-wide environment value only it
wants — the one exception is the shared
TEST_KEKevery call site incontrol_integration.rsinstalls, 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-storePostgres 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 = 1andbackpressure = "error", one request pins the sole worker, exactly one surplus request takes the queue slot, and the rest must come back as429witherror.code = "queue_full"whilerolter_provider_queue_rejections_totaladvances. Memory is bounded by the queue, not by client burst size. - graceful SIGTERM drain — a real
rolter-gatewaychild process is sentSIGTERMwhile a request is pinned upstream. The in-flight request must still return200, new connections must be refused, and the process must exit0.
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::pickfor every built-in strategy over a ~24-target pool with a populatedRouteContext.trie— prefix-trieinsert(bounded/unbounded, so LRU eviction is measured) andlongest_prefixon 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/snapshotis 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::allowsandCooldowns::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 ownROLTER_KEKis 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.rsinstalls one sharedTEST_KEKfor exactly this reason (#1351); a test needing a non-matching key builds it withKek::from_secretrather 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.txtin 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 thecoveragejob inquality.yml. The nightly copy carries no such flag, so a ratchet failure onmasteropens 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 ascommand not foundunder 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 howci-okonce 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.
RUSTDOCFLAGSsits on thecargo docstep.RUSTFLAGS=-D warningssits on the twocargo hacksteps, which build intotarget/hack, because a differentRUSTFLAGSwould otherwise rebuild the clippy steps’ dependency tree on every run.rust buildsets noRUSTFLAGSat all: the variable replaces everyrustflagsentry in cargo’s config, the wild linker’s--ld-pathincluded. - 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-oknever 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:
- lists the open Dependabot alerts with the repository
GITHUB_TOKEN, under thevulnerability-alerts: readscope. A failed listing fails the run rather than reading as “no alerts”; - hands them to
ui/scripts/security-updates.ts, which raises each vulnerable direct dependency inui/package.jsonto its first patched release and keeps the range operator (^1.2.0becomes^1.2.7); - refreshes
bun.lockwithbun install --lockfile-only; - rebuilds the
security/ui-dependenciesbranch frommasterwhen the planned change differs from what the branch already carries, and opens or updates one pull request, titledbuild(deps): raise vulnerable ui dependencies to patched releases; - dispatches
ci.ymlagainst that branch after every push, since a pull request opened with the repository token triggers nopull_requestrun. 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
overridesentry, 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 createfailed 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.ymlhas 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:
| Exit | Meaning | Gate behaviour |
|---|---|---|
0 | audit completed, nothing to report | pass, first attempt |
11–14 | audit completed, findings at informational…high | fail, first attempt |
1 | no audit was produced | retry, but only when the output names a GitHub API failure |
2, 3 | bad arguments / no inputs collected | fail, 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:
| Where | Rule | Why |
|---|---|---|
engine-integration.yml — Swatinem/rust-cache | cache-poisoning | nothing this workflow builds is published, so the cache cannot poison a release |
release-plz.yml — both actions/checkout steps | artipacked | release-plz pushes the release branch and the tags with the persisted token, so persist-credentials must stay on |
project-automation.yml — pull_request_target | dangerous-triggers | required 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
--portis taken. The probe connects rather than binding —python3 -m http.serverandBun.serveboth setSO_REUSEADDR, so a second bind on a squatted port succeeds and a bind-only probe calls it free - it starts
storybook dev --ciand 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 … : Storyin 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 devis spawned with its cwd in this worktree’sui/, 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 nolsofthe 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(…), neverform.getByRole(…)on the line after the sheet opened - a gated control renders enabled until
/api/v1/rbac/effectiveanswers —undefinedis “not known yet” and only an explicitfalsedisables — soexpect(button).toBeDisabled()asserted once is reading the gate before it spoke. UseexpectRefused, which waits for the disabled flag and thetitletogether - 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). Thetitleis 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 arole— 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 awaitFordoes 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 aftersheet()or afindByRole("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:
| rule | impact | nodes | stories | decision |
|---|---|---|---|---|
region | moderate | 3177 | 488 | off by default — page-level |
landmark-one-main | moderate | 593 | 593 | off by default — page-level |
page-has-heading-one | moderate | 570 | 570 | off by default — page-level |
empty-table-header | minor | 13 | 13 | fixed |
heading-order | moderate | 11 | 11 | fixed |
landmark-unique | moderate | 9 | 9 | fixed |
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 file | What it mounts | Widths |
|---|---|---|
ui/src/App.stories.tsx | the assembled shell — rail + header + screen, signed in (#1239) | 1280, 768, 375 |
ui/src/pages/Login.stories.tsx | the signed-out login page, which has no shell around it | desktop |
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:
| Guard | Where | What it catches |
|---|---|---|
parameters.a11y.expectRules | .storybook/test-runner.ts postVisit | the 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:stories | ui/scripts/check-story-parameters.ts, run by bun test scripts | the 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:
| Helper | What it is for |
|---|---|
Harness | swaps 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 |
pending | a 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 |
clickWhenEnabled | waits 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 / expectClosesWithoutPrompting | the 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 insideact. Open withfindBy*/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 auseMemokeyed 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, soexpectNoUxEvent(…)on the line after the click proves nothing.StrictModeDoesNotInventAnAbandonfirst presses the sheet’s save button and waits for itsform_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 workingStrictMode, 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):
| Job | What it checks |
|---|---|
macos check | cargo check --workspace --all-features on macos-latest |
compose smoke | the full Docker Compose topology, described below |
msrv build | cargo +<rust-version> check --workspace --all-features, with the version read from the root Cargo.toml |
coverage | the 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
- Branch from
master:feat/<issue-number>-<short>orfix/<issue-number>-<short>. - Make focused changes; add unit tests next to the code.
- Run
cargo fmt --all,cargo clippy --workspace --all-targets -- -D warnings, and the tests (just test, orcargo nextest run --workspace+cargo test --doc --workspace). - Use Conventional Commits for messages and the PR title.
- Link issues with
Closes #N/Refs #N. - 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;
rustfmtdefaults; clippy clean with-D warnings. thiserrorfor library errors,anyhowin 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 intobuild(). - New storage backend → implement
rolter_storetraits 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
--amendafter 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
| Piece | Where |
|---|---|
fifteen fake OpenAI-compatible upstreams on 127.0.0.1:18001-18015 | integration/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 routes | integration/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 shares | integration/dogfood/creds.env |
| the SigNoz dashboards the session is read through | integration/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 anopenaiprovider:18002— OpenRouter’svendor/modelnames, declaredkind = "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 dogfoodgeneratesROLTER_ADMIN_TOKEN,ROLTER_INTERNAL_TOKENand both peppers intointegration/dogfood/.tokens.envon 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/*routes401ed 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-sheetprints every value. -
The two peppers must outlive a restart, like the KEK.
ROLTER_KEY_PEPPERis mixed into a stored virtual-key digest andROLTER_SESSION_PEPPERinto 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 sameROLTER_KEY_PEPPERas the control plane — the snapshot carries no pepper, so a mismatch rejects every key with401 invalid api key. -
The gateway names itself, and says so.
ROLTER_NODE_IDstill wins, but a gateway that has none now falls back toHOSTNAMEand then to thegethostnamesyscall — which a shell-launched process does have, even though it has noHOSTNAMEin its environment. It logs which one it used at boot (node identity resolved), and warns when nothing answered. SetROLTER_NODE_IDanyway when running more than one gateway on one host, since otherwise both heartbeat as the same node.just dogfoodsets 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 answers400to a report it cannot key on, which the gateway’s reporter logs asadaptive-routing telemetry report failed. See #1644. -
Everything in
dogfood.tomlis in the explicitreadonlytier. 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). Thedefaulttier is not an option for this stack: giving the control plane a config file switches it toMergedConfigStore, 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 andgroup-slug/model404s until it restarts (#1643).dogfood.tomlseeds 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 explicitreadonlytier: thedefaulttier is seeded by the control plane from its ownROLTER_CONFIG, and giving the control plane a config file switches it toMergedConfigStore, which drops db-created virtual keys from the snapshot (#623) and 401s every key this stack mints. -
allow_custom_api_basedoes not survive the database.dogfood.tomlsets 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 theclaude-sonnet-4route 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-r1is the fleet’sstrategy = "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), sojust dogfoodflips it on throughPUT /api/v1/adaptive-routing-policyonce the control plane answers (#1817). The route engages aftermin_samplespicks, 50 by default. To compare against the fallback stack, runjust dogfood-adaptive offor use Adaptive Routing → Settings. Either change survives a control-plane restart, and the nextjust dogfoodturns the switch back on. Onlyenabledchanges: 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
nofilelower. There both ClickHouse containers fail witherror setting rlimit type 7: operation not permittedand everything downstream (request logs,ui_events, SigNoz) is missing. PutCLICKHOUSE_NOFILE=20000indocker/.envthere, not in a repository-root.env, which compose does not read for these files (#1819). A one-offCLICKHOUSE_NOFILE=20000 just dogfoodworks for that run, but the next compose call without it,just signoz-resetincluded, recreates ClickHouse at 262144. -
Analytics need the gateway’s
[logging].clickhouse_url, not just the control plane’sCLICKHOUSE_URL. The control plane’s only lets it read the table (#929). -
The SigNoz dashboards are tied to the SigNoz pin.
provision-signoz.shtargets v0.136.0, the releasedocker/docker-compose.signoz.ymlpins, which moved sign-in to/api/v2/sessions/email_passwordand 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 atjust signoz-reset.just dogfoodrepeats the failure after the sheet, andjust dev-credsexits with it. Runjust signoz-provisionagainst 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_eventstable is missing: the same500, but it never clears, so the whole week is lost. This is the onejust dogfood-uxexists 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
| Path | What it is |
|---|---|
ui/src/lib/i18n/index.ts | i18next init, locale detection, setLocale() |
ui/src/lib/i18n/locales/en.json | base catalog — the source of truth for keys |
ui/src/lib/i18n/locales/ru.json | Russian catalog |
ui/src/lib/i18n/format.ts | useFormat() — Intl number/currency/date bound to the active locale |
ui/src/lib/i18n/parity.ts | the parity rules, shared by the test and the CI gate |
ui/src/lib/i18n/literals.ts | the hardcoded-literal rules, likewise shared |
ui/src/lib/i18n/literals-allowlist.ts | the notation the literal gate tolerates, each entry with its reason |
ui/src/components/LocalePicker.tsx | the switcher in the sidebar footer |
ui/scripts/check-i18n.ts | bun run check:i18n — the catalog-parity CI gate |
ui/scripts/check-literals.ts | bun 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:
| Prefix | Holds |
|---|---|
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 / .subtitle | the 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
- Copy
ui/src/lib/i18n/locales/en.jsonto<code>.jsonand translate it. Leavelocale.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. - Register it in
ui/src/lib/i18n/index.ts: add the code toLOCALES, a native name toLOCALE_NAMES, a two-letter badge toLOCALE_SHORT, and a dynamic import toloaders. - 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:
- the stored choice in
localStorage["rolter.locale"], - the browser’s
navigator.languages, matched on the primary subtag soru-RUresolves toru, 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:
| Kind | What it matches |
|---|---|
dialog | a string literal passed to window.confirm / alert / prompt |
prop | prose assigned to a user-facing prop, destructured default or object key — the names are listed below |
text | prose sitting directly between JSX tags, interpolation included |
error | the 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:
| Names | Where the dashboard renders them |
|---|---|
title, subtitle, description, label, placeholder, emptyText, aria-label | headers, fields, empty states, accessible names |
confirmLabel, cancelLabel, saveLabel | dialog and sheet buttons |
desc | RelatedLink, FeatureFlags and the settings screens’ rows |
hint, info | Field and SwitchRow — the line under the control and the InfoHint beside it |
text, tooltip | InfoHint, and a hover explanation |
detail, message, body | a toast’s second line, a notice’s message or body |
note | ScopeNote and the cost attribution notes |
error, errorMessage | Field‘s validation line, the create dialogs’ failure line |
alt | an image’s accessible name |
header, group | a table column’s heading, the header a ComboboxOption sits under |
eyebrow | PageLead and SettingsPanel’s kicker |
xLabel, yLabel, unit, xUnit, yUnit, centerLabel, centerSub | the charts’ axis titles and units, the donut’s centre |
removeLabel, ariaLabel, empty, experimentalNote, unavailableReason | a chip’s remove button, a Segmented group’s name, a screen’s empty slot, a flag |
aria-description, aria-roledescription, aria-valuetext, aria-placeholder | the rest of what a screen reader speaks |
heading, caption, legend | the 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 ruleor← 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]}ortitle={COPY.save}reads every value ofconst 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,lngand the rest ofT_OPTION_KEYS— are skipped - helpers. A call that is the whole of a rendered expression —
{stateLabel(row)},hint={hint(on)}— reads the helper’sreturnvalues (a declaredfunctionor an arrow bound to aconst). 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(…)}) makessetLoga 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,asrenames 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
useStateseed 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 intolabel:,description:andgroup:onComboboxOption, 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>ortitle={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.
| kind | cause | recovery offered |
|---|---|---|
unauthenticated | 401 | sign in again |
forbidden | 403 | none — ask an administrator |
openMode | 401 with code open_mode_no_session | none — set ROLTER_ADMIN_TOKEN |
noStore | 404 with code no_such_endpoint (or that message prefix from an older control plane) | none — set ROLTER_DATABASE_URL |
unreachable | the thrown value is not an ApiError, so fetch never connected | retry |
server | 5xx | retry |
unknown | any other non-ok status | retry |
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} />
)}
| screen | where 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
LoadErrorfor 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 action | success | failure |
|---|---|---|
| a settings screen’s Save | toast — the sticky footer’s “…updated.” flash is gone | toast; the footer no longer keeps a copy |
| a sheet or dialog that closes on success | toast | toast, plus the inline line the still-open sheet already carried |
a delete behind a ConfirmDialog | toast | toast, plus the dialog’s own error line |
| a row toggle | nothing — the switch staying flipped is the confirmation | toast; 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 confirmation | the 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.
LogsandRbacrendered 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.
ProviderGroupssaid “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
| State | Component | Lives in |
|---|---|---|
| in flight | ListSkeleton, CardGridSkeleton, FormSkeleton, PanelSkeleton, TableSkeleton, StatGridSkeleton | ui/src/components/LoadingState.tsx |
| loaded, no rows | EmptyState | ui/src/components/ui/empty-state.tsx |
| failed | LoadError | ui/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 itsEmptyStateonly on a successful answer.rowsis what survived the screen’s filters, so a search that matched nothing still gets its no-match copy.Tabletakesreadbesideempty, and the type will not accept one without the other.ListSummaryis the count beside a list:<ListSummary data={query.data}>hands its render function the data, so there is no?? 0left to write, and renders nothing until the data is held. A summary that also explains the screen passes afallbackwith 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 onisSuccessand 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 anEmptyState; 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 informationalAnalyticsUnavailablepanel rather than a red alert (#1984, #1976, #2016; see error states). - No CTA where no action exists.
McpOAuthgrants are created by a user completing an OAuth flow in a client;Clusternodes 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.
McpOAuthis 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 arole="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.useModalA11yhands focus back to the panel, and keeps everything outside the topmost modalinertwhile 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-disablewithtone="default", since one flip undoes it. Switching a provider back on sends at once. Deleting one keepssso-connection-delete. When the provider is the last enabled one and the saved policy has password sign-in off, both carry aLockoutNoticeaschildrenand the disable button turnsdanger.locksOutMembersinui/src/lib/sso-lockout.tsdecides 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 fromallow_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 aSsoOffNoticeaschildren.locksOutSsoMembersinui/src/lib/sso-lockout.tsdecides 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 whileallow_ssois 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-deactivatewithtone="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-deletewithtone="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-grantwithtone="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 offROLES, 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(scimorsso) withtone="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. Amemberorviewermapping 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
childrenlists the reasons: whatadminis, and that the whole organization is every team and project rather than one. Both reasons appear foradminacross 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:
| Row | When |
|---|---|
form_submit ok | the confirm button is pressed |
form_submit error | an error arrives after the press that was not already on screen |
retry_submit | the confirm is pressed again after a refusal |
save_confirmed | the caller closes the dialog after the press, with nothing refused |
form_abandon cancelled | the 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()inonOpenChange), so a refusal from one row does not greet the next row opened. A dismissal whilependingresets 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;
useModalA11yreturns 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
lockedrather 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:
| answer | when | what the control does |
|---|---|---|
true | the pair is in allowed, or the caller is a superadmin | renders enabled |
false | the caller’s role does not reach it | disabled, with the required role in the title |
undefined | the query is still in flight, there is no provider above, or the question could not be answered at all | renders 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.tsxcarries aresourceper leaf, andvisibleNavdrops a leaf whose<resource>:readis explicitlyfalse— 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
ScreeninApp.tsxrenders theforbiddenLoadErrorup 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
GatedButtonon<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). AButtonbecomes aGatedButton, aSwitchbecomes aGatedSwitch, aComboboxbecomes aGatedCombobox, andRowIconButtonandDeleteIconButtontake agateprop. A screen never gates a control by readinguseGate()itself and passingdenieddown: the control is disabled either way, but only the primitives record the reach for it as arefused_click, so a hand-gated control is invisible to the struggle signal (#1759, see UX telemetry). A control none of them fits gets agateprop on the nearest primitive, the wayDeleteIconButtondid, andSwitchRowdid for the project-settings switch (#1820).ui/src/components/gated-controls.test.tsfails on auseGate()outside the primitives unless the comment above it carries// use-gate-allow: <reason>— for a genuine non-control use, such asGettingStartedchoosing between a link and aGatedButton. - 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 samePUTguard as an edit (set_prompt_template_version/set_skill_versionincrates/rolter-control/src/crud.rs), and there is no:publishaction in the capability table to gate on instead — and Delete is the one control that takes<resource>:delete(#1297). TheAsViewerstory on each screen asserts all five are refused and name the Admin role. - The first-run checklist.
GettingStartedon the Dashboard is written for whoever sets the deployment up, so it renders nothing for a caller refused all three ofprovider:create,route:createandvirtual_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 aforbiddenLoadErroron the first screen a member opens (#1848). - The pending invitations on the Users screen. The section reads
invitation:readthroughuseCan()for its presence, the way the checklist does: it waits for the answer, is absent on an explicitfalse, and sends no request then. The control plane filters the list below the org to the teams and projects the caller administers, so a403from the list is an answer too and hides the section instead of raising aforbiddenLoadErroron a screen a viewer may open. The revoke button is aRowIconButtononinvitation: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 explicitfalse: 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 aGatedButtonon the same pair. The screen readsuseCan()for the automatic mint only, since that is behaviour rather than a control; an unanswered gate still mints, and the403stays the backstop. - Minting your own key. Generate virtual key on the account screen,
in the toolbar and again in the empty state, is a
GatedButtononmy_virtual_key:create, the pair the Playground’s mint asks. The empty state readsuseCan()for its copy only: on an explicitfalseit 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:readis an explicitfalse, the rail’s own rule for that leaf, and a refused caller reads who owns the setting instead (#1984). It is a react-routerLinkrather than a raw anchor, so following it does not reload the dashboard; a story that renders it supplies aMemoryRouter. - 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.
| Viewport | Shape | Behaviour |
|---|---|---|
< 768px (below md) | off-canvas drawer | Hidden 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 rail | On 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 rail | The 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) toNAV_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-widthcarries inindex.css). - Keyboard — the splitter is a focusable
role="separator"witharia-orientation="vertical"and livearia-valuenow/valuemin/valuemax.←/→move it 16px,Shiftmultiplies that by four,HomeandEndjump to the bounds, andEnter/Spacereturn it to the default. A mouse-only affordance would not be acceptable on a primary nav control. - Persistence — the settled width is written to
localStorageunderstorageKey(rolter.nav.widthby 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.
| Key | What it does | Where it lives |
|---|---|---|
⌘K / Ctrl-K | toggles the command palette | isPaletteShortcut in ui/src/lib/command-palette.ts |
/ | puts the caret in the rail’s search box | isNavSearchShortcut, which focuses NAV_SEARCH_ID |
? | opens the keyboard shortcut reference | isHelpShortcut, which opens ShortcutHelp |
Tab from the top of the page | reveals 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:
Screeninui/src/App.tsx, with the header’s ownscreens.<key>.title. That covers every routed leaf. The refused state renders insideScreen, 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, withauth.title. Signed out there is noScreenaround it.AcceptInvite, withpages.acceptInvite.titleonce the preview names the org. While the preview is in flight, or when the link is dead, it usespages.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
Badgebeside the label carryingshell.experimental, with the build’s own one-line note as itstitle. 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’stitle; 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-1on the rest, soTabwalks past the whole strip in one press instead of one per tab. Avaluematching 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,HomeandEndjump 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
TabItemmay carryidandpanelId; the tab then getsaria-controlsand the caller’srole="tabpanel"points back witharia-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:
| Token | Use for |
|---|---|
--status-success / --status-warning / --status-info / --status-danger | fills: dots, bars, chart series, /15 tints, borders |
--status-success-text / --status-warning-text / --status-info-text / --status-danger-text | text: 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:
| Token | Value | Worst ratio |
|---|---|---|
--status-success-text | #38c163 | 5.21:1 (tint over --surface-subtle) |
--status-warning-text | #f59e0b | 5.22:1 |
--status-info-text | #6ba1ff | 4.79:1 |
--status-danger-text | #ff6f66 | 4.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.
| Shape | Glyph | Why |
|---|---|---|
--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:
| Tokens | Used by | Floor |
|---|---|---|
--chart-1 … --chart-8, --chart-other | donut.tsx, scatter-plot.tsx, line-chart.tsx, Dashboard.tsx’s provider bars | 3:1 — a fill carries a shape |
--avatar-1 … --avatar-6 | the Users.tsx chips | 4.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:
| Token | Value | Worst ratio (#27272a) | On #111113 |
|---|---|---|---|
--chart-1 | --red-600 #e5342a | 3.44:1 | 4.36:1 |
--chart-2 | --zinc-400 #a1a1aa | 5.81:1 | 7.36:1 |
--chart-3 | --status-info #3b82f6 | 4.05:1 | 5.13:1 |
--chart-4 | --status-success #16a34a | 4.52:1 | 5.72:1 |
--chart-5 | --status-warning #f59e0b | 6.94:1 | 8.78:1 |
--chart-6 | --red-500 #ff4017 | 4.25:1 | 5.39:1 |
--chart-7 | --zinc-500 #71717a | 3.08:1 | 3.90:1 |
--chart-8 | --zinc-300 #d4d4d8 | 10.08:1 | 12.76:1 |
--chart-other | --zinc-700 #3f3f46 | 1.43:1, exempt | 1.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 carrieddisabled, 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
| File | What it holds | Who changes it |
|---|---|---|
PRODUCT.md | Users, positioning, operating context, constraints, voice. No visual decisions | /impeccable init when product truth changes |
DESIGN.md | The visual system: tokens in YAML frontmatter, then prose and named rules | /impeccable document after the tokens or primitives change |
.impeccable/design.json | Sidecar to DESIGN.md: tonal ramps, shadows, motion, breakpoints, component snippets for impeccable’s live panel | regenerated together with DESIGN.md |
.impeccable/config.json | Project settings: the detector hook is on, detector ignores | impeccable 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 withPRODUCT.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,typesetand the rest refine an existing screen. The full list is/impeccablewith no argument./impeccable shape <screen>plans a new screen before any code: a short discovery interview, then a brief the build follows. It andcritiquestop to ask questions, so therolter-uisubagent leaves both to the main session and usesaudit,adapt,hardenandpolish.
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
| Prop | Default | Notes |
|---|---|---|
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 |
wrap | false | soft-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 |
lineNumbers | false | a gutter drawn with a CSS counter |
copy | true | the 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
/gwon the dashboard’s origin. Never the bare origin: the control plane serves the gateway only under/gw/*, soorigin/v1/…is a 404 (#2075) - it returns
{ url, configured }.urlis the root without/v1, which the snippet appends;configuredsays which branch won, and the copy-as-code snippets add their “this is the dashboard’s gateway proxy” comment only when it isfalse
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):
| Candidate | Minified | Gzipped |
|---|---|---|
refractor core + 7 grammars | 56.6 kB | 20.4 kB |
shiki/core + JS engine + 6 grammars, no theme | 470 kB | 88.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:
| Role | Token | Reads 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
- Add the name to
CODE_LANGUAGESinui/src/lib/code.ts, and any informal spellings a model or an operator might write toALIASESbeside it (resolveLanguageis fed raw fence tags, so it must never throw). - Add the display name to
LANGUAGE_NAMESinui/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. - Import the grammar in
ui/src/lib/code-highlight.tsand add it toGRAMMARS.refractorresolves each grammar’s own dependencies, so import the language, not its graph. - Check the token classes it emits against the
--code-*rules inindex.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. - Add a story to
code-block.stories.tsxasserting the token kinds that make that language worth colouring, and extend the round-trip case inui/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:
markedis used as a lexer only —marked.lexer(), nevermarked.parse()— and its tokens are mapped onto a small tree with no node that can carry markup.Markdown.tsxrenders that tree as React elements, so there is nodangerouslySetInnerHTMLin the path and no sanitiser to keep in step with a parser.htmltokens become literal text:<script>…</script>renders as the characters an operator can read, and nothing runs.- hrefs are allowlisted to
http,https,mailtoand relative URLs.javascript:anddata:keep their label and lose their link. A rendered link carriesrel="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>;
| Prop | What it is for |
|---|---|
options | { value, label, description?, group?, disabled? }[] |
value / onChange | controlled; "" means nothing selected |
placeholder | shown while nothing is selected; defaults to common.combobox.placeholder |
clearable | adds an × that resets the selection — for an optional field |
size | default matches Input; sm is the compact toolbar control the screens wrote as h-8 text-xs |
className | wrapper layout only (margins, width) — the control’s own height comes from size |
listClassName | the popup, mainly to widen it past the control |
title | native 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.
| Key | Behaviour |
|---|---|
| ↓ / ↑ | opens the popup; then moves the active option, wrapping, skipping disabled ones |
| Home / End | first / last enabled option of the filtered list |
| Enter | commits the active option; never submits the surrounding form |
| Escape | closes the popup and restores the selected label, leaving the value alone — and stops there, so the Sheet around it stays open |
| Tab | closes the popup and moves on without selecting |
| typing | filters 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-childrenviolation. - The scroll lives on the listbox, not on the popup around it. A scrollable
plain
<div>is ascrollable-region-focusableviolation, 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-livebut notrole="status". The role would put a second status node on every screen that has a dropdown, and the screens whose own notice is arole="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, andgetByLabelText("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, someasure()computes every offset from the data in one pass. The same numbers are applied to the rows as an inlineheight, 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 changingROWwith it, andWindowRowsMatchTheirMeasuredHeightfails when they disagree. - The window never drops the active option. Whatever the scroll says, the
row named by
aria-activedescendantis rendered, because a name pointing at an unmounted node announces nothing at all — the one regression #968 must not have.TheActiveOptionStaysMountedwalks 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.
| Primitive | File | What it is |
|---|---|---|
FieldLabel | field-label.tsx | the compact label a sheet row carries, with the optional required marker and (i) note |
FieldError | field-error.tsx | the message under a control, carrying the id the control points at |
describedBy | field-error.tsx | joins the ids a control actually has into one aria-describedby |
FormSection | form-section.tsx | one collapsible group of fields, open state owned by the caller |
Segmented | segmented.tsx | a two-to-four way choice rendered inline as a radiogroup |
LockButton | lock-button.tsx | the padlock toggle beside a parameter or header |
ChipGroup | chip-group.tsx | a multi-select over a short, fully visible list |
SwitchRow | switch-row.tsx | a boolean as a full-width row: title, hint, switch |
SettingsPanel | settings-panel.tsx | a 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 wiresaria-describedbyitself, 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 passesdescribedBy(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
Comboboxand not aSegmented— see Dashboard dropdowns and the Combobox. - A list long enough to need filtering is a
Comboboxtoo, not aChipGroup. 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, notCard. The two are easy to confuse and used to be worse:ModelSettingsandPerformanceeach declared a local component calledCardthat had nothing to do withui/card.tsx’s (#1682).Cardis a bare bordered surface withCardHeader/CardTitle/CardDescription/CardContentparts you compose yourself;SettingsPanelis 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:
FieldLabelbinds its<label>withhtmlFor, or names a whole group throughid+ the group’saria-labelledby. A label bound to nothing looks correct and leaves the control unnamed.FieldErrorrenders nothing at all when there is no error, and carries anidso 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 axenested-interactivefailure (#1201). A collapsed section renders no body, so a closed section holds nothing tabbable.Segmentedis aradiogroupof real buttons — tabbable,Enter-activated, exactly onearia-checked.LockButtonreportsaria-pressedand names both the state and what a press will do, because the padlock icon says neither.ChipGroupis one namedgroupofaria-pressedtoggles, and says none available rather than rendering an empty row.SwitchRownames its switch after the row title. Handed agate, it is refused the wayGatedSwitchis and must name itself withcontrol(#1820).SettingsPanelgroups 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-2is 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, andCodeBlockis the<pre>; *.stories.tsxand*.test.ts(x), which are fixtures rather than shipped UI, the same carve-outcheck-literals.tsmakes;- a file that imports the shared component and wraps it. Adapting
Dialog as BaseDialogunder a localDialogis 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);
sourceis 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.changednames the fields whose value differs fromsaved.EQUALSis 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 withObject.is.commit(next)adopts what the server answered as both copies after a save, andreset()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).
| Primitive | Role | What it is |
|---|---|---|
ListTable | table | the bordered, sideways-scrolling frame; label names it |
ListHeader | rowgroup holding one row | the header band; its className lands on the band |
(inside ListTable) | rowgroup | the body: every child of ListTable that is not the ListHeader |
ListRow | row | one data row, on the same grid template as the header |
ListHeaderCell | columnheader | a column’s visible heading |
SortLabel | columnheader holding a button | a sortable heading, carrying aria-sort |
ListActionsHeader | columnheader | the heading over a row’s buttons: no visible text, a name for screen readers |
ListCell | cell | one column of one row |
ListStateRow | row holding one cell | what the body shows instead of rows: the loading skeleton, the empty state |
ListLoadingRow | a ListStateRow, or nothing | the skeleton row, shown while the screen’s read is awaiting an answer |
ListEmptyRow | a ListStateRow, or nothing | the 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.
labelis required and becomes the table’saria-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
ListHeaderholdsListHeaderCell,SortLabelorListActionsHeader; aListRowholdsListCell. A barespanin a row is read with no column header. - Replace an element, wrap a component. Where a row used to hold a
spanordiv, change the tag toListCelland keep its classes: the cell becomes the grid item and nothing moves. Where it held a component (aPill, aBadge, aCombobox, aSwitch, aRowIconButton), wrap it in<ListCell className="grid">. A plain block cell would shrink a pill to its text and a button to its icon;gridlays the component out exactly as it was when it sat in the row’s grid itself, stretched across its column. Addmin-w-0when 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 axeempty-table-header. It readscommon.rowActionsby default; a table whose buttons take more than one column passeslabelso each has its own name (Auth Sessions hascolRenewandcolRevoke). - A row that opens something holds a control for it. A
ListRowis arow, which takes no focus, so anonClickon 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 aListActionsHeaderlabelledanalytics.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, inwaitFor, that focus moves into the drawer and back to the button on close. - Loading and empty go in a
ListStateRow. A skeleton’srole="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 asaria-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}>, wherequeryis the screen’suseQueryresult androwsis 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 === 0put “No providers yet” and its create button under the list’s ownLoadError(#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 component | Hook | What it records |
|---|---|---|
EmptyState | useEmptyState | a zero-data placeholder was reached |
LoadError | useErrorState | an error placeholder was reached |
EditorSheet | useFormTelemetry | a create/edit form was submitted, saved, refused or abandoned |
ConfirmDialog | useFormTelemetry | a 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:
recordUxEventsin the meta’sbeforeEach, so a story neither inherits the previous one’s events nor leaves its own behindexpectUxEvent(action, target?)waits for one event and hands it back, so the story can go on to assert the screen, the outcome or the durationexpectNoUxEvent(action, target?)is the other half, and it is the one that matters for a cancel: a dialog that emittedform_submiton the way out passes every positive assertion and still makes the data say the delete went throughuxEvents()for the cases where the sequence is the assertion — a refused save is["ok", "error"]onform_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:
| Action | duration_ms is the time since |
|---|---|
form_submit (ok and error), retry_submit | the form opened |
form_abandon, abandon_dirty | the form opened |
save_confirmed | the 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.
| Action | Emitted from | What it means |
|---|---|---|
retry_submit | useFormTelemetry — a submit after a failure | somebody did not understand why the first save failed |
refused_click | useRefusedClick — the disabled-control path | somebody reached for a control RBAC denies |
abandon_dirty | useFormTelemetry — a close with a draft in it | somebody 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.
sanitizeKeydrops 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
-emptysuffix —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
- a new
clickhouse/NNN_*.sqlthat re-declares the enum with the existing ordinals untouched and the new value appended, - the value appended to
ACTIONSincrates/rolter-control/src/ui_events.rs, in the same order —the_action_list_is_the_enum8_in_ordinal_orderpins it, so a value sorted into the middle is a failing test rather than a silent remapping, - the value added to the
UiEvent["action"]union inui/src/lib/api.tsand a named emitter inux.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 wrong | Server answers | Client does | Cost | Who notices |
|---|---|---|---|---|
| ClickHouse is down or unreachable | 500 | drops the batch, keeps sending | the events queued during the outage, and nothing after it | the operator: a warn and a counter |
ui_events table missing (a data volume older than the DDL) | 500 | drops the batch, keeps sending | every event, for the whole run — the condition never clears itself | the operator: a warn and a counter |
| one malformed event in a batch | 400 | drops the batch, keeps sending | every event that shared that flush, not just the bad one | nobody |
| batch over 100 events | 400 | drops the batch, keeps sending | that flush; only reachable if the client’s cap drifts from the server’s | nobody |
| session lapsed, or no session | 401 | disables itself | everything from that moment until the tab is reloaded | nobody |
| control plane older than #805, or a proxy dropping the route | 404 | disables itself | everything, from the first flush onward | nobody |
| a proxy rewriting the method | 405 | disables itself | everything, from the first flush onward | nobody |
CLICKHOUSE_URL unset on the control plane | 500 | drops the batch, keeps sending | everything, and one request per flush forever | the operator: a warn and a counter |
UX events switched off (logging_settings.ui_events = false) | 202 | nothing — this is success | everything | the 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_failuresadds one per lost batch, labelledstream(ui_events, ormcp_logsfor the MCP ingest endpoint, which shares the path) andreason(insertfor a refused or unreachable store,unconfiguredfor a missingCLICKHOUSE_URL) — alert on any sustained rate;- a
telemetry ingest failedwarning names the store’s own error, at most once a minute per stream, with asuppressedcount 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
500body 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:
proseWrap | embeddedLanguageFormatting | Files touched | Lines changed |
|---|---|---|---|
preserve | auto (prettier’s default) | 141 | 4840 |
always | auto | 162 | 23942 |
always | off | 162 | 22423 |
preserve | off — chosen | 129 | 3596 |
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.
proseWrapstays at prettier’spreservedefault.alwaysrewraps 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.embeddedLanguageFormattingisoff. 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 aprometheus.ymlsnippet 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 againstproseWrap: preserve— inside a Mintlify<Note>or<Warning>, whose children it was printing as JSX.printWidthis a free choice, so it matchesui/.prettierrc. WithproseWrap: preserveand 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 aBREAKING 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 ordinaryfeat/refactorwhose 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
experimentalstability 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.shenforces this as theno-agent-session-urlsprek hook (commit-msgstage) and, in CI, in two places: theno agent session urls (commits)step ofquality.yml’sstatic checksjob re-checks every commit the PR introduces, so a--no-verifypush or an amend is still caught, and a step of theci-okjob inci.ymlchecks 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 inci.ymlbecause a body edit takes theeditedfast path, which skipsquality.ymlentirely — a body check inside that workflow could never see the edit that introduced a URL. See #1406, #2035 andci-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 installspre-commit,commit-msg, andpre-pushshims. - 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-denyhooks 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
| Value | Meaning |
|---|---|
Backlog | Accepted but not scheduled. Nobody is expected to pick it up next. |
Todo | Scheduled and ready to start; scope and fields are settled. |
In Progress | Someone — or an agent worktree — is actively working on it. |
In Review | A pull request is open, waiting on review or on ci-ok. |
Done | Merged or otherwise resolved. |
Canceled | Deliberately 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.
| Milestone | What belongs in it |
|---|---|
| Release 1.0.0 | Only 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 polish | Real, wanted work that does not block the tag: dashboard polish, internal refactors, extra providers, anything blocked on an upstream dependency. |
| Release 2.0.0 | Post-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 & DX | Repo hygiene, CI hardening, dependency triage, contributor experience. |
| Research & inspiration | Spikes and prior-art surveys that inform the roadmap without shipping anything. |
| Stretch | Optional 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 answered | What the job does | Annotation title when it gives up |
|---|---|---|
| primary rate limit, budget refills inside the window | reads the reset time from GET /rate_limit (free, not metered) and sleeps until then | none; it retries after the refill |
| primary rate limit, budget refills after the window | fails at once and names the reset time | GitHub API budget exhausted |
primary rate limit, but /rate_limit shows points left | backs off 5s, 10s, 20s… for up to six attempts | GitHub API rate limit |
| secondary rate limit (too many requests in a short window) | backs off 60s, 120s, 240s… until the window closes | GitHub API secondary rate limit |
| HTTP 5xx, timeout, dropped connection, or a concurrent add of the item | backs off 5s, 10s, 20s… for up to six attempts | GitHub API unavailable |
| bad credentials, HTTP 401/403/404, insufficient scopes, unresolved node | fails at once, without retrying | project board token misconfigured |
an empty ADD_TO_PROJECT_PAT | fails before calling anything | project board token missing |
| anything else | fails at once, quoting the error and the two ways to recover | GitHub 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:
-
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 } } } } } } } } }}' -
pass the complete option list to
updateProjectV2Field, including the ones you are keeping -
restore the snapshot with
updateProjectV2ItemFieldValue, in batches of about five mutations per request — larger batches hitResource limits for this query exceeded -
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 themerge_grouptrigger 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:
| Option | What it buys | What it costs |
|---|---|---|
strict = true | The gate always ran on a tree containing current master | Every 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 queue | GitHub re-runs ci-ok against the prospective merge result, batching and ordering merges without anyone pushing rebases | One extra gate run per batch, and merging becomes asynchronous |
| Neither | No new friction | Semantic conflicts still reach master |
How merging works now
- You still open and review PRs exactly as before.
ci-okon the PR head is still required, and nothing enters the queue without it. gh pr mergeenqueues 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 withgh pr view <n> --json state,mergedAt.- A red merge-group run dequeues the PR, leaving
masteruntouched and the PR open with a comment naming the failure. Fix it on the branch and requeue; there is nothing to clean up onmaster. - 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 targetsmaster, and it queues like anything else. See the stacked-PR rules inAGENTS.md. ci.ymlstill runs on every push tomaster. That backstop stays: it is the run that sees the tree everyone will branch from, and it is what catches anything that reachedmasteroutside 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
Defaultif 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:
| Step | Runs on |
|---|---|
| checkout | pull_request, workflow_dispatch, merge_group |
| assert the gate already ran for this commit | pull_request with action edited (the fast path) |
pr-title | pull_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:
| Result | Must be |
|---|---|
quality, codeql | success; on an edited run, success or skipped |
| assert the gate already ran | success on an edited run |
pr-title | never 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 run | allowed, 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.ymlrun on this sha that is notcompleted—queued,in_progress,waiting— fails the step. The commit is not gated yet, and the run that is still going will write its own, newerci-okwhen it finishes, so nothing is lost by refusing here. - No completed run on this sha whose
gate-okjob 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 greenci-ok. - A
cancelledrun does not count as a pass. It iscompleted, 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 leavesgate-okskippedrather than adding a second red check besideci-ok.skippedis notsuccess, so the guard reads it correctly either way. - It never re-derives
quality’s verdict.needs.quality.resultis computed by Actions, which already accounts for whatcontinue-on-errorcovers insidequality.yml(thecoveragejob, thesemver-checkssteps ofrust build) and forcoveragebeing skipped on every event butpull_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.ymlby filename. A run ofdocs.ymlorui-e2e.ymlon the same sha says nothing about this gate. - It is keyed on
github.event.pull_request.head.sha, notgithub.sha. On apull_requestrungithub.shais 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: readfor the listing, and the query writes to a file thatjqthen reads. The step runs underset -o pipefail, where piping intoheadorgrep -qfails 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:
| Message | Means | Action |
|---|---|---|
gate still running on <sha> | the gate is fine and unfinished | none; the in-flight run supersedes this |
no completed ci run on <sha> recorded a passing gate-ok job | the gate failed, was cancelled, or never ran | push 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 endssuccess,failure,cancelledorskipped; nothing a step does changes that.neutralis reachable only for a check-run posted through the Checks API, whichci-okis not — it is a job, and its check-run is written by Actions. - Posting a second
ci-okcheck-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 newestci-okon a sha must be trustworthy (#1328). - Self-cancelling the run to reach
cancelled(grey) costs more than it buys. It needsactions: writeonci-ok— the one required status check — purely for a colour, andci-okrunsif: 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-ok | github.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 step | On merge_group | Why |
|---|---|---|
quality, codeql, gate-ok | run | the point of the run |
ci-ok step no agent session urls (pr body) | runs, and must succeed | a 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 succeed | the only thing that reads the commit messages of PRs batched ahead of this one |
ci-ok step pr-title | skipped | the 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_sha | scans the commits the queue is about to write to master, entries batched ahead of this one included |
quality job coverage (informational) | skipped | informational 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 ofgh-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 honoursROLTER_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_grouppayload names both endpoints, so the range isbase_sha..head_shathrough the plain--commit-rangemode.
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:
- 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.
- If the tooling injected one, strip just that line with a direct
PATCH /repos/{owner}/{repo}/pulls/{n}(for examplegh 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 directPATCHdoes not re-trigger it and the edit sticks. Strip it as soon as you see it:ci-okreads 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 rededitedrun you may see first. - For a commit message, the equivalent is an amend or rebase that drops the
trailer before the push —
quality.ymlre-checks every commit in the range, so a--no-verifypush 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_requestrun. 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:
- The edit starts an
editedrun. Its body step fails on the new body, and its fast path declines anyway because the gate has not finished. Thatci-okis red. - The
synchronizerun finishes the gate. Its body step checks the payload, which predates the edit, and passes. Thatci-okis green. - The green one is the newer
ci-okcheck-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-okit 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-titlealready fetches the same PR with the same token on everypull_requestrun (pulls.getinamannn/action-semantic-pull-request, which re-reads the PR because the payload can be stale), andci-okholdspull-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
Stripping an injected footer
Since the live read, the quickest way to green is to strip the footer as soon as you see it:
- Read the PR body back after creating the PR.
- If a session URL is there, strip that line with a direct
PATCH /repos/{owner}/{repo}/pulls/{n}. There is nothing to wait for. - The
editedrun 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 itsci-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):
| Run | Event | Started | Outcome |
|---|---|---|---|
| 35249017068 | opened | 16:50:04 | session-urls, then a job of its own, failed on the frozen body; gate-ok succeeded |
| 35249064201 | edited | 16:50:35 | ci-ok red — gate still running, the strip was too early |
| 35249935368 | edited | 16:59:31 | ci-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 says | Means | Do |
|---|---|---|
gate still running on <sha> | a title or body edit landed before the gate finished | nothing; 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 job | the gate actually failed, was cancelled, or never ran | fix 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_requestskips.push,merge_groupandworkflow_dispatchalways analyse, so the merge queue runs the full Rust analysis on the tree that is about to land, and everymastercommit (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 toci.ymlalone does not count as a Rust change; - the step fails open. A
HEADthat is not a merge commit, a failed diff or a failedgrepall mean analyse, and the step iscontinue-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:
| Crate | Guarded | Why |
|---|---|---|
rolter-auth | yes | virtual keys, roles and access checks; consumed by both planes |
rolter-balancer | yes | in-tree strategies all implement one LoadBalancer trait, so a change to it reaches every strategy at once |
rolter-gateway | yes | data-plane surface; changes here are behavioural, not structural |
rolter-proxy | yes | provider dialect adapters |
rolter-core | no | config types churn with every new provider kind and strategy |
rolter-store | no | repo signatures and row structs track the schema |
rolter-control | no | CRUD payload structs track the dashboard |
rolter | no | launcher 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:
| Run | Baseline |
|---|---|
| pull request | the base branch tip, the first parent of the test merge GitHub checks out |
| merge queue | the queue entry’s base_sha |
push to master | the 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
pubitem 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 whatrolter_proxy::Forwarder::forward_beareris: #1446 renamed it toforward_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: trueand run last inrust build, whose report turns a failure into a warning, never an error, so the check can never turnci-okred. 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. GUARDEDkeeps 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 = falseat the workspace level in.github/config/release-plz.toml. Left at its default it would runcargo-semver-checksitself 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:
| Level | Means |
|---|---|
stable | the default. Covered by whatever compatibility promise the release makes |
experimental | may 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:
| Axis | Question | Set by | Where it lives |
|---|---|---|---|
| capability | can this deployment run it? | the build and its infrastructure | unavailable_flags(), read-only |
| enablement | is it turned on? | the operator | a stored flag, hot-reloaded through /internal/snapshot |
| stability | how finished is it? | us, at build time | SUBSYSTEMS, 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.
| Subsystem | Dashboard | Why 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_settings | MCP → MCP Settings | The 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_groups | MCP → Tool Groups | Tool-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. |
plugins | Plugins | The 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_repository | Skills Repository | A 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
- Add, edit or delete the
SubsystemStabilityrow incrates/rolter-core/src/stability.rs. Keep the list sorted byid; theidis published and is never renamed once it has shipped. - Update the table above in the same change. The
the_docs_page_lists_exactly_these_subsystemstest compares the two lists row for row, so a marker cannot ship without the paragraph that explains what it exempts the subsystem from. - 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. nav_keysare leaf keys fromNAVinui/src/lib/nav.tsx, checked byevery_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.- Add the note to the dashboard catalogs as
stability.notes.<id>inui/src/lib/i18n/locales/en.jsonand every sibling catalog, and drop it when the row goes.ui/scripts/stability-notes.test.ts(part ofbun run test) fails while theenkeys and theSUBSYSTEMSids disagree, andbun run check:i18nholds every other locale to theenkey 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:
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).config_export.rs— arender_*function emitting the same keys, sorted.config_export::tests::round_trip::FIXTURE— a row of the new kind, so the round trip actually covers it.- The
HEADERconstant, 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. 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
routeslist, 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:
| artifact | built on |
|---|---|
manylinux…x86_64 | ubuntu-latest, target: x86_64 |
manylinux…aarch64 | ubuntu-latest, target: aarch64 |
macosx…arm64 | macos-latest (Apple Silicon, native) |
macosx…x86_64 | macos-latest, cross-compiled target: x86_64-apple-darwin |
win_amd64 | windows-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 version | fix: | feat: | breaking (!) |
|---|---|---|---|
>= 1.0.0 | patch | minor | major |
0.x.y (x >= 1) — today | patch | patch | minor |
0.0.z | patch | patch | patch |
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 prjob runsscripts/sync-chart-appversion.py --fixon the release branch and pushes the result, so the Release PR is already consistent - a
helm chartstep ofquality.yml’sstatic checksjob 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-dockeronly assembles the:{version}and:latestmanifests 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 --versionmatches the tag, while the artifacts are still private. The wheel install uses--no-index, so it can only resolve from the freshly builtdist/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:
-
Add the
release:readylabel to the release PR:gh pr edit <number> --add-label release:readyrelease-pr-ready.ymlruns on that label event and dispatchesci.ymlon 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 dispatchesci.ymlagain throughdispatch-release-pr-cifor as long as the label stays. -
Wait for
ci-okon the release PR, then merge it. The merge commit’s own push run then gates the crates.io publish (below). -
To hold back a release that was labelled too early, remove the label. The head that already has
ci-okstays mergeable, but the next push that rewrites the branch gets no dispatch, so the PR goes back toBLOCKED.
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:
scripts/unpublished-crates.shlists every workspace crate whosepublishis not[]incargo metadata(today everything exceptrolter-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 settingverified, sorelease-plz releaseis skipped. A lookup that fails for any reason counts as unpublished.- Otherwise
scripts/wait-for-ci-gate.shwaits for theci.ymlpush run on the same commit (actions/workflows/ci.yml/runs?head_sha=<sha>&event=push&branch=master) and requires that run’sci-okjob to have concludedsuccess. It fails closed on an API error that survives three attempts, on any other conclusion, on a finished run with noci-okjob, and after 90 minutes. Its last command writesverified=trueto 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
| Gate | Effect |
|---|---|
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 PR | ci.yml runs on the release PR only while it is present, so without it the PR stays BLOCKED |
verify-external-checks | ci-ok and CodeQL recorded success for the tagged commit; fail-closed |
RELEASE_REQUIRED_CHECKS repo variable | exact check-run names verify-external-checks requires (comma-separated) |
PYPI_PUBLISH_ENABLED repo variable | must be "true" or the PyPI publish is skipped |
DOCKER_PUBLISH_ENABLED repo variable | must be "true" or the image publish is skipped |
pypi environment | PyPI 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:
- one OpenAI-compatible API over hosted, BYOK and local models;
- per-identity control of which models may be used and how much may be spent;
- 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 pluscount_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.txtcontract 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
| Area | Them | rolter | Verdict |
|---|---|---|---|
| OpenAI surface | Chat Completions, Responses (+WebSocket), Embeddings, Images, decisions | Chat Completions, Responses, Embeddings, Messages, audio, realtime, MCP | rolter ahead on breadth; they have the Codex WebSocket transport and count_tokens (tracked in #1016) |
| Anthropic surface | Messages + count_tokens (estimated, with a disclosure header) | Messages | Gap: count_tokens (already in #1016) |
| Provider kinds | OpenAI, Anthropic, Gemini, Azure, Bedrock, Vertex, Fireworks, OpenRouter, openai-compatible | Substantially more, plus self-hosted engines, TEI, llama.cpp, Ollama | rolter ahead |
| BYOK | Keys stored in a user-data file outside the repo; catalog stays secret-free | KEK-sealed in Postgres, rotation and a restore audit, env-var indirection | rolter well ahead |
| Model catalog | Secret-free models.toml; every alias declares capabilities and prices explicitly | Dashboard catalog, pricing overrides, model defaults | Comparable; price import still manual in rolter (#966) |
| Routing | Ordered exact-model pools with per-rung conditional failover by failure class | Strategies (weighted, cache-aware, adaptive, complexity, disaggregated), circuit breakers, retries, cooldowns | rolter far ahead on live routing; behind on conditional failover |
| Cost intelligence | Router fitted offline from real traffic, with held-out evaluation and a report | Adaptive blend uses live cost as a scoring signal; #1468 plans an evaluation harness | Gap: no offline “what would this have cost” analysis |
| Model recommendation | Yes — the fitted router is the recommendation | No | Gap |
| Trace ingest | Nine declared sources: OTLP, OTel GenAI, PostHog, Braintrust, Langfuse, LangSmith, Mastra, Phoenix, chat-json | None — rolter only sees traffic it served | Gap |
| Observability | Content-free SQLite accounting, loopback usage view, OTLP in | ClickHouse request logs, MCP logs, cost attribution, alerting, tenant telemetry destinations, dashboards | rolter far ahead |
| Guardrails | Identity-scoped, default-off, input+output chains, pluggable classifiers | Guardrail rules and guardrail providers as first-class resources | Comparable; rolter has the richer admin surface |
| Budgets | Monthly integer nano-USD enforcement; a cost preflight that refuses an expensive operation before it dials a provider | Budgets, limits, cost attribution; reservations planned in #1464 | Comparable, converging |
| AuthZ | Identities, grants, virtual keys | Full RBAC matrix, custom roles, access profiles, SSO/OIDC, SCIM, LDAP, TOTP, audit log | rolter far ahead |
| Dashboard | Hosted platform only; local is a terminal UI | 60+ screens, i18n, Storybook, capability gating, loading/empty/error contracts | rolter far ahead |
| Onboarding | Interactive first-run wizard, then agent-pasteable prompts and llms.txt | easy-up, fake-llm, a written quickstart; no wizard, no llms.txt | Behind |
| Deployment | pip install, loopback, SQLite, or their hosted platform | Docker, Helm, Kubernetes, air-gapped, cluster mode, backup/restore/KEK rotation | rolter far ahead |
| Docs | ~17 dense reference pages, very precise, contract-flavoured; no tutorials | mdBook developer docs + Mintlify user docs, 32 ADRs, nav gates in CI | rolter ahead on breadth and navigation; their per-page precision is worth imitating |
| Marketplace | Hosted model marketplace with credits and instant signup | None, and none wanted | Out 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.
- 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.
llms.txtplus 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).- 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.
- 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.
- 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-llmas a zero-credential first step. rolter’s zero-config story is genuinely good and the dashboard does not tell it. - 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_kdo 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, indocs/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 (witheasy-upit is one process, so one export). Without a KEK, requests that include anapi_keyare 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 theROLTER_KEK-derived key. - Propagation — every write bumps
config_version(a database trigger, transactional with the write). Gateways pollGET /internal/snapshot?version=Nand 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.tomlis a read-only “config model” (LiteLLM-style): the API rejects runtime mutations to it with409. - Reads never leak secrets —
GET /api/v1/config(the dashboard read) redactsapi_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, default0.0.0.0)port(u16, default4000)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_BUNDLEreplaces 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 targetskind(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 slashapi_key(string, optional) — preferapi_key_envapi_key_env(string, optional) — environment variable to read the key fromrole_profile(openai|system_only|anthropic, optional) — explicit instruction-role semantics. The default isopenaiforkind = "openai",anthropicforkind = "anthropic", and conservativesystem_onlyfor every OpenAI-compatible kind.system_onlyconverts leadingdevelopermessages tosystemin place; it rejects asystemordevelopermessage after a user/assistant/tool turn withrole_capability_unsupportedrather than silently changing it.model_role_profiles(table, optional) — upstream-model-specificrole_profileoverrides. 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 singleapi_key/api_key_envpair. Providers cap throughput per key, so rotating across keys multiplies effective RPM/TPM. The control-plane store holds one credential per provider, sorolter-seed --importkeeps the first entry that names anenvand warns about the restkey(string, optional) — inline key value; preferenvenv(string, optional) — environment variable to read the key fromweight(u32, default1) — relative selection weight
api_key_env(string, optional) — env var to read the key fromegress_proxy(string, optional) — legacy single HTTP/HTTPS/SOCKS5 outbound proxy; treated as a one-element poolegress_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 outputca_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 forprecise_cache_aware:endpoint(tcp://…),topic(defaultkv-events),max_blocks(default 1,000,000), andstale_secs(default 30)[providers.lmcache](optional) — LMCache controller signal forlmcache_aware:endpoint(HTTP JSON occupancy signal),refresh_secs(default 2), andstale_secs(default 10)also_track_via_llm_call(bool, defaultfalse) — when set, active health checks send a realmax_tokens = 1completion to this provider instead of the free/v1/modelsliveness 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 assource = llm_callinprovider_health_events.llm_probe_model(string, optional) — the upstream model id thealso_track_via_llm_callcompletion 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-stylestatus.jsonURL (e.g.https://status.anthropic.com/api/v2/status.json). When set, a slow background poll records the provider’s public status as a secondarystatus_pagehealth signal — it surfaces inprovider_health_events, the dashboard androlter_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 requeststrategy(round_robin|random|power_of_two|consistent_hash|cache_aware|weighted|pipeline|cheapest|fastest|precise_cache_aware|lmcache_aware|adaptive|lora_aware|predicted_latency, defaultround_robin)[[routes.targets]]provider(string) — a providernamemodel(string, optional) — upstream model id; defaults to the requested modelweight(u32, default1)
[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 theparamsdefaultsmode(allow|deny, defaultallow) — baseline override policyallow(string[], default[]) — params callers may override whenmode = "deny"deny(string[], default[]) — params callers may not override whenmode = "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-leveltargetspool: 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’sstrategypicks which target leads; the remaining targets follow in declared order as the deterministic fallback tail.name(string) — variant identifier, attributed in request logs (thevariantcolumn)weight(u32, default1) — 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’sparam_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 routeenable(string[], default[]) — rules that apply on this route; wins overdisableon a conflict- a name matching no configured rule fails validation rather than being ignored — a typo in
disablewould 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(offRoutesinui/src/lib/guardrail-policy.tsmirrorsRouteGuardrails::allows)
[[virtual_keys]]
key(string) — the bearer token clients presentname(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, defaultfalse) — kill switch; while off, everyadaptiveroute serves thepipelinestacklatency_weight(f32, default1.0),cost_weight(f32, default0.5),load_weight(f32, default0.25) — blend weights; negatives are clamped to0, and all-zero disables the blendexploration_ratio(f32, default0.05) — share of picks made at random to keep latency samples fresh; clamped to[0, 0.5]min_samples(u32, default50) — requests a route must serve before the blend engages
[logging]
clickhouse_url(string, optional)
[health]
enabled(bool, defaultfalse) — master switch for active upstream probinginterval_secs(u64, default10) — seconds between probe sweepstimeout_secs(u64, default2) — per-probe timeoutpath(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, default2) — max probes in flight at once during a sweep, so probing never stampedes upstreamsconsecutive_failure_threshold(u32, default3) — consecutive probe failures before a provider is marked unhealthyrecovery_success_threshold(u32, default2) — consecutive successes before an unhealthy provider recoversstatus_page_interval_secs(u64, default60) — seconds between provider status-page polls; only providers with astatus_page_urlare polled, and the poller runs even whenenabled = false- probes are jittered across the first quarter of the interval (per-provider stable offset), and a
429on 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, default1000) — concurrent sessions admitted by this gateway instancemax_session_secs(u64, default3600) — hard session-duration limitidle_timeout_secs(u64, default300) — closes a session when neither side sends a frameusage_flush_secs(u64, default15) — how often a live session’s finished response turns are written to the request log, added to budgets andtpmwindows, and followed by a budget re-check. It bounds how long a session can overspend a budget before it is closed.0flushes 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, defaulttrue) — deny169.254.0.0/16andfe80::/10. This is the cloud instance-metadata range; leave it on unless you have a concrete reasonblock_loopback(bool, defaultfalse) — deny127.0.0.0/8and::1, plus the unspecified addresses0.0.0.0and::, which reach the local host when connected to. Off by default because sidecar and single-host deployments legitimately serve models on localhostblock_private(bool, defaultfalse) — deny10.0.0.0/8,172.16.0.0/12,192.168.0.0/16andfc00::/7. Off by default because on-prem clusters are the common caseallow_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, so169.254.169.254also covershttp://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, defaultfalse) — master switchmax_scan_bytes(usize, default262144) — cap on total request text scanned per request; oversized content is forwarded unscanned so work stays boundedstreaming_post_call(string, defaultreject) — what a streamed request does on a route that haspost_callrules; see Output masking and streaming
Each [[guardrails.rules]] entry:
name(string, required) — stable, unique; surfaced in metrics, never carries match textbuiltin(string) — one ofemail,phone,api_token,payment_card; orpattern(string) for a custom regex. Set exactly one.stage(string, defaultpre_call) —pre_callscans request content before proxying;post_callmasks the response body before it reaches the clientaction(string, defaultannotate) —annotate(count only, forward unchanged),block(reject with an OpenAI-compatibleguardrail_blockederror), orredact(replace each match withreplacement)replacement(string) — redaction token; defaults to the built-in entity token (e.g.[REDACTED:EMAIL]) or[REDACTED]include_system(bool, defaultfalse) — also scan operator-authoredsystem/developermessages; excluded by default
default_onwas 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]disablelist (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 a400carrying codeguardrail_streaming_unsupported, counted inrolter_guardrail_stream_rejections_total. The default fails closed: a masking rule that silently stops applying because the client passed"stream": trueis the failure mode worth ruling out.passthrough— serve the stream with output rules not applied.pre_callrules 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, defaultfalse) — master switchurl(string) — http(s) endpoint the envelope is POSTed to (required when enabled)stage(string, defaultpre_call) —pre_callinspects the request.post_callis validated but not yet enforced (output/SSE stage deferred).timeout_ms(u64, default2000) — per-call timeoutmax_retries(u32, default0) — extra attempts on a transient failure (connect/timeout/non-2xx)failure_mode(string, defaultfail_open) —fail_openforwards unchanged when the service is unreachable;fail_closedrejects with an OpenAI-compatible errormax_body_bytes(usize, default65536) — cap on the content forwarded; oversized content is sent as a truncated preview withtruncated: trueauth— 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, defaultfalse) — master switch
Each [[prompt_templates.templates]] entry is one immutable version:
id(string, required) — stable identifier surfaced in safe metadata, never in content logsversion(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, defaultfalse) — the caller must supply it; mutually exclusive withdefaultdefault(string) — value used when the caller omits it
[[prompt_templates.templates.decorators]]— a message injected around the caller’s own messages:role(string, defaultsystem) —system,assistant, oruserposition(string, defaultprepend) —prepend(before the caller’s messages) orappend(after), both in declared ordercontent(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-upbinds its gateway toROLTER_HOSTtoo, and its control plane unlessROLTER_CONTROL_HOSTis set (loopback when neither is); the published image’s default command passes--host 0.0.0.0insteadROLTER_CONTROL_HOST,ROLTER_CONTROL_PORT,ROLTER_UI_DIR— control plane, underrolter controlandrolter easy-upalikeROLTER_KEK— AES-256-GCM KEK for provider-secret encryptionROLTER_PUBLIC_URL— the control plane’s externally reachable base URL (defaulthttp://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 restartROLTER_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
| Check | Severity | Why |
|---|---|---|
ROLTER_KEK present | error | Without it provider credentials are stored unsealed, with only a warning |
ROLTER_KEK length ≥ 16 | error | The KEK is stretched through SHA-256, so a weak secret still yields a valid — and brute-forceable — key |
ROLTER_ADMIN_TOKEN present | error | Otherwise the management API and /internal/snapshot are unauthenticated |
ROLTER_DATABASE_URL present and postgres:// | error | Otherwise the control plane falls back to an in-memory store and loses all config on restart |
| No example values survive | error | The example database credentials and the e2e throwaway KEK are published in the repository |
ROLTER_REDIS_URL present | warning | Without it rate limits and budgets are enforced per replica, not per deployment |
Control plane not bound to 0.0.0.0 | warning | The management API should not be reachable on every interface |
ROLTER_KEY_PEPPER present | warning | Without it a leaked virtual-key digest is usable as-is against this deployment |
ROLTER_SESSION_PEPPER present | warning | Without 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 * | error | The 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 / warning | A URL that parses but resolves to nothing fails at first use rather than at rollout |
ROLTER_KEK opens the store (with --connect, postgres builds) | error | Every 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) | error | A config that fails to load leaves the gateway on whatever it last had |
Every key in the config file is recognised (with --config) | warning | An 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.tomlrolter control --config rolter.tomlrolter easy-uprolter-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 restROLTER_ADMIN_TOKEN, the management API’s bearer tokenROLTER_INTERNAL_TOKEN, shared between the control plane and its gatewaysROLTER_KEY_PEPPER, mixed into virtual-key digestsROLTER_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_HOST | 127.0.0.1 — the management plane is an admin surface | 0.0.0.0 |
[server] require_auth | true — revoking the last key closes the data plane | unset, defers to the deployment shape |
Passes rolter check | yes, cleanly | no, 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, Redis6379, ClickHouse8123/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
gatewayhorizontally; 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/versionand the dashboard footer’s v0.2.0 available hint (#902). Implemented incrates/rolter-control/src/update_check.rs(the checker, the semver order and the endpoint), spawned fromcrates/rolter-control/src/lib.rs. - The
rolterlauncher starts the same fetch beside every subcommand (update_notice::spawn()incrates/rolter/src/main.rs, before the match),gateway,easy-up,checkandinitincluded, and prints a one-line stderr notice (#901). A successful answer is cached in~/.cache/rolter/update-check.jsonfor 24 hours. Only a success is cached (checked_atis the last successful fetch), so a host with no route to GitHub retries on every invocation. The cache and the notice live incrates/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.
| Path | What it reaches | Setting | Default | Code |
|---|---|---|---|---|
| Release check | api.github.com, above | ROLTER_UPDATE_CHECK=false (Helm control.updateCheck) | On | rolter-control/src/update_check.rs, rolter/src/update_notice.rs |
| Upstream providers | Each 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 provider | Providers and routes | None (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) |
| Postgres | ROLTER_DATABASE_URL, from the control plane (rolter check --connect also opens a TCP connection) | ROLTER_DATABASE_URL | Unset: in-memory store | rolter-store/src/postgres/mod.rs, rolter/src/preflight.rs |
| Redis | ROLTER_REDIS_URL from both planes: budgets, rate limits, response cache, config pub/sub, the sign-in throttle | ROLTER_REDIS_URL / --redis-url | Unset | rolter-gateway/src/redis_conn.rs, watcher.rs, rolter-control/src/crud.rs (publish_config_change), login_throttle.rs |
| ClickHouse | Request logs, payload capture, provider health events, analytics and alert queries | CLICKHOUSE_URL or [logging].clickhouse_url; a gateway with neither inherits the control plane’s at boot | Unset: nothing is logged | rolter-gateway/src/logging.rs, health_events.rs, lib.rs, rolter-control/src/analytics.rs |
| Gateway to control plane | Snapshot poll, the log destination at boot, adaptive-routing telemetry (all to the snapshot URL’s origin); the /admin/* proxy | ROLTER_SNAPSHOT_URL, ROLTER_SNAPSHOT_POLL_SECS (5), ROLTER_ADMIN_URL | Unset: static bootstrap config | rolter-gateway/src/watcher.rs, adaptive_telemetry.rs, admin_proxy.rs |
| Control plane to gateway | The Playground’s /gw/* HTTP and WebSocket calls | ROLTER_GATEWAY_URL | http://localhost:4000 | rolter-control/src/proxy.rs |
| Alert channels | Each enabled channel’s endpoint, a JSON POST per state change; redirects off, egress policy checked | Alerting → Channels, /api/v1/alert-channels | No channels | rolter-control/src/alerting.rs (deliver) |
| OTLP export | The OTLP receiver, for traces, metrics and logs | OTEL_EXPORTER_OTLP_ENDPOINT or the _TRACES_ / _METRICS_ / _LOGS_ variants; ROLTER_TELEMETRY_ENABLED=false blocks every exporter | Unset: no exporter is built | rolter-core/src/telemetry.rs |
| Connector test | The connector’s endpoint: one empty OTLP resourceLogs request per Test, enabled or not. A collector you run does the export to a connector | Observability → Connectors | No connectors | rolter-control/src/connectors.rs (deliver_probe) |
| Dashboard browser tracing | The OTLP/HTTP endpoint injected into the served page, called from each user’s browser | ROLTER_UI_OTEL_ENDPOINT; ROLTER_TELEMETRY_ENABLED=false drops it | Unset: no tracing code is loaded | rolter-control/src/lib.rs (ui_runtime), ui/src/lib/telemetry.ts |
| Active health probes | Each provider’s api_base plus [health].path; also_track_via_llm_call sends a one-token completion | [health] enabled | false | rolter-gateway/src/health.rs |
| Engine metrics scrape | Each provider’s api_base plus [metrics_scrape].path (/metrics) | [metrics_scrape] enabled | false | rolter-gateway/src/upstream_metrics.rs |
| Provider status pages | A provider’s status_page_url, every [health].status_page_interval_secs (60), independent of [health] enabled | status_page_url on a provider | Unset | rolter-gateway/src/status_page.rs |
| Cache-aware routing signals | A provider’s kv_events.endpoint (a ZeroMQ SUB socket) and lmcache.endpoint (an HTTP poll every refresh_secs) | kv_events, lmcache on a provider | Unset | rolter-gateway/src/cache_telemetry.rs |
| MCP servers | The url of each registered server, per call through /mcp/{server} | The registered MCP servers | None | rolter-gateway/src/mcp_proxy.rs |
| MCP OAuth | The 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 seconds | MCP servers set to OAuth | None | rolter-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-in | Governance → Single Sign-On | No providers | rolter-control/src/sso.rs |
| Guardrail webhook | The webhook URL, one POST per request before it goes upstream | [guardrail_webhook] enabled, or an active provider in the registry | Disabled | rolter-gateway/src/guardrail_webhook.rs, rolter-core/src/guardrail_webhook.rs |
| PII sanitizer | [pii_sanitizer].url and, with restoration on, restore_url | [pii_sanitizer] enabled | Disabled | rolter-gateway/src/pii_sanitizer.rs |
| Webhook plugins | The endpoint of each enabled plugin instance, per matching request at its stage | Plugins; an instance is live while enabled | No instances | rolter-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-llmmodel run in the gateway process.
The dashboard and the API reference
- The interactive API reference at
/docsembeds the Scalar JS bundle in the binary and setswithDefaultFonts: false, so it never reaches a CDN orfonts.scalar.com. This is asserted by thedocs_page_is_self_containedandscalar_bundle_is_embeddedtests incrates/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 inui/public/. The footer’s repository and bug-report buttons are plainhrefs togithub.comthat 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.
Docker image (recommended)
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=falseon the control plane and wherever therolterlauncher runs. In Helm,control.updateCheck: falseplusROLTER_UPDATE_CHECKingateway.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— withROLTER_UPDATE_CHECK=falsethe release check is not attempted.- Telemetry endpoints — unless you deliberately set
OTEL_*to an internal collector. - Provider status pages — leave
status_page_urlunset.
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
| Item | Where it lives | What its loss costs |
|---|---|---|
| the control-plane database | Postgres | everything below, plus routes, budgets, RBAC and the audit log |
ROLTER_KEK | your secret manager, not the database | every sealed secret in that database, permanently |
ROLTER_KEY_PEPPER | your secret manager | every virtual key hash stops matching; keys must be reissued |
ROLTER_SESSION_PEPPER | your secret manager | everyone 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 one | config management | reconstructible, 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:
- Find the original KEK. Check the secret manager version history, the deployment that wrote the dump, and any sealed-secret manifest in the cluster.
- 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.
- 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:
| Table | Holds |
|---|---|
provider_keys | upstream provider credentials |
sso_providers | SSO client secrets |
alert_channels | alert channel webhook secrets |
security_settings | the dashboard’s own upstream credential |
observability_connectors | observability connector credentials |
user_totp_factors | TOTP second-factor shared secrets |
mcp_servers | MCP OAuth client secrets |
mcp_servers | MCP static bearer tokens and header api keys |
mcp_oauth_login_states | in-flight MCP OAuth PKCE verifiers |
mcp_oauth_sessions | MCP 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:
| State | Recommended topology | Failure behaviour |
|---|---|---|
| Postgres | primary with cross-region replica / managed HA | control writes go to the primary; gateways retain their last atomic snapshot during a control outage |
| Redis / Valkey | multi-AZ primary with replica or managed global datastore | response-cache and rate-limit state are shared; a Redis outage degrades to uncached/local operation |
| ClickHouse | replicated cluster or regional ingest plus central query endpoint | observability 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
- Apply ClickHouse migrations in every ingest cluster, including
005_request_payloads.sqlwhen raw payload capture is enabled. - Verify
/internal/snapshotreturns the same config version from each regional control plane. - Send a
fake-llmrequest through each region and confirmx-rolter-provider/x-rolter-targetheaders. - 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.