Skip to main content
0.20 replaces how policies reach host keys and limits, and what they apply to. A policy now links pools, rate limits and budgets; policy bindings attach it to accounts, the things requests run in and are charged to; and relay keys, service accounts and host keys no longer hold a policy. The 0.11 policy links are dropped and you rewrite them in the new shape. Policy bindings are converted to accounts for you, and the upgrade stops, listing the rows, where a conversion would widen access.
The migration drops the old policy links. Migration 36 deletes every policy’s host keys, model ids and rate limit, every relay key’s and service account’s policy, and every host key’s tier policy. They are not converted and not restored by a down-migration. Export your configuration and back up Postgres before you upgrade.
This is a stop-and-start upgrade: a 0.11 pod cannot serve against the new schema. Schema version goes from 34 to 39.

What changes

New kinds: Account, Pool and Budget, with their CRUD routes (/api/accounts, /api/pools, /api/budgets) and RBAC kinds (accounts, pools, budgets, granted by the built-in roles). Relay makes a team, project, personal, member and service account for each matching row, and you create provider accounts; see Accounts and lanes. Teams may now own host keys and rate limits as well. New reads: GET /api/me/accounts, GET /api/accounts/{ref}/limits, GET /api/keys/{ref}/limits and GET /api/plan-issues; see Control API. A request can run in another of its credential’s accounts with X-WR-Account, a /a/<account>/ base URL, or RELAY_ACCOUNT_DOMAIN. Behaviour that changes at request time:
  • Reach without a policy widens. In 0.11 a project-scoped credential that resolved no policy was refused with no_policy. In 0.20 a request whose ladder links no pools reaches the host keys owned by its project, its team and the system, or for a personal account the user’s own and the system’s. Host keys created with the admin token are system-owned, so every such request can use them. To keep a team or project on specific keys, bind its account a policy that links the pools it should use; see Reach without a linking rung.
  • Bindings on users apply to their member accounts. A 0.11 binding owned by a project and naming a user becomes a binding of that user’s member account in the project. It governs requests in that account: the user’s inference tokens for the project, and relay keys created in the account. The user’s existing keys run in the personal account and are not governed by it.
  • Inference tokens run in a member account. A token runs in its user’s member account in the token’s project. A user who holds a role in that project directly, or whom a 0.11 project binding names, has one after the upgrade. A user whose access to the project comes only through a group or a team-level role has none until the next mint creates it: tokens minted before the upgrade answer 403 account_not_held, and the user mints new ones.
  • Rate-limit and budget counters start over. They are now kept per account, so the counts and spend of the current windows and periods begin at zero at the upgrade.
  • Every host key has an account. The upgrade puts each host key without one into an account per owner and host, marked groupedByRelay. A key saved later must name its account (400 account_required otherwise): create the account, then add keys. A 429 from the provider on a key in a grouped account still goes to that key’s breaker and the request fails over to the next key, as in 0.11; a 429 in any other account cools the account for the requested model. Set a grouped account’s orgId once you know its keys share one provider organization, or split its keys into an account per organization. See Accounts grouped by the upgrade.
  • Deprecated models are granted like any other. A provider reference covers them, and a request whose ladder lists no models may call them.
  • Usage events carry lane_account_id, the account the request ran in, and refusal on the requests Relay refused itself, which are now recorded with the status and message the caller received. policy_id records the innermost caller policy: the lane’s, else the project’s, else the team’s. See Usage.
  • Proxy mode refuses a model that is not in the catalog, also for a key that had no policy, instead of forwarding it unpriced.
  • Batch items run under the ladder as it is when each item runs, not as it was at submission.
  • Error codes. no_policy, policy_not_found, policyless_disabled and project_unavailable are gone; subject_disabled, duplicate_binding, unpriced_under_budget, budget_exceeded, account_closed and account_not_held are new. A request whose every host key is out of rotation answers 503 no_healthy_keys, where 0.11 answered 502 upstream_error. Relay’s own refusals carry error.refusal (model_not_allowed, host_not_reachable or no_capacity). model_not_allowed names the rung and the policy that refused. A disabled, revoked, expired or rotated-out relay key answers 401 invalid api key. See Errors.

What the migrations do

Migration 35 adds the accounts, pools, pool_credentials and budgets tables and the junctions linking a policy to pools, host keys, rate limits and budgets. Policy bindings gain team and system owners and more subject kinds. Every existing binding stays owned by its project. It then enforces one binding per subject: a subject other than a user may appear in one binding, and a user in one binding per owning project. If the database holds a subject bound twice, the migration stops and nothing of it is applied:
Migration 36 drops the policy_models and policy_host_keys tables, the policies.rate_limit_id column and the policy_bindings.priority column, and removes modelIds, hostKeyIds, rateLimitId, rlBindings and keySelection from every policy, policyId from every relay key, service account and host key, policies and defaultPolicy from every host, and budget from every team and project. Policies keep their name, owner, models, payloadLoggingEnabled and enabled for migration 39. Policy bindings, rate limits, host keys, relay keys, teams, projects and service accounts are kept.
Migration 36 refuses to run if any still-bound policy would lose its only access restriction. A policy with rows in policy_models and an empty models field, or rows in policy_host_keys and no pools or credentials, becomes unrestricted once those rows are gone. If any such policy is bound to a non-group subject—disabled bindings count—the migration stops and lists each policy and its bindings. Adding models or pools before upgrading is not possible; the pools table exists only in 0.20. Unbind those policies (or delete their bindings) on the 0.11 database first, upgrade, then add models or pools following Re-creating the dropped links. See step 5 of Before you upgrade for the check query.
Before it drops them, it logs each link it removes by the names of both ends, on boot and in relay migrate up: one log entry per link, with the message migrate: postgres notice and the line below in its notice field. A target whose row no longer exists is named by its id:
It then gives every host key without an account an account of its owner at the key’s host, creating one account per owner and host, named after the host (suffixed -2, -3, … when the name is taken). A key created with the admin token is system-owned and joins the system’s account. Keys whose host no longer exists are left without one. Migration 41 marks these accounts groupedByRelay unless they already name an orgId, groups the same way any key still without an account whose host exists, and stops Relay from creating accounts for keys. After migration 41, every provider account blocks deleting its host, its team or its project, including an account grouped by relay that no host key names any more: before 0.20 such an account went with its owner. Deleting one of those answers 409 listing the accounts; delete them first (DELETE /api/accounts/by-id/{id}), or list them in the same apply’s prune. The catalog’s tier policies lose their rate limits with this migration. They get them back, as rateLimits entries scoped to models, when a catalog release with the v1alpha3 schema is seeded. Migration 37 adds the owner of a user’s own policy binding; deleting the user deletes it. Migration 38 moves every binding subject that names a host key to the key’s account. A 0.11 database has no bindings either one changes. Migration 39 makes accounts what policies bind to:
  • Accounts. It creates a team account per team, a project account per project, a personal account per user and an account per service account, and a member account for each user an enabled role binding names directly at a team or project. They are named as in Accounts Relay makes.
  • Relay keys. Every key gets accountId: a service account’s key its service account’s account, a user’s key the user’s personal account.
  • Binding subjects become accounts. A service account becomes its account. A user named by a binding owned by a project becomes the user’s member account in that project, created when no role binding made one; such an account serves inference tokens only. A binding owned by a team, the system or the user fans a user out to every account of theirs it covered: the user’s member accounts in the team and its projects, the personal account and every member account, or the personal account alone. A team or project subject becomes its account, and a host key’s subject its provider account. Group subjects, and host key subjects whose key has no account, bind nothing and are dropped, each logged:
    Bindings lose projectId and subjects and keep their owner.
  • Policy models. @host is stripped from every reference in models and in rate-limit links’ models, duplicates are removed, and each policy whose models change is logged: 000039: policy "ci": models ["openai/gpt-4o-mini@openai", "openai/gpt-4o-mini"] become ["openai/gpt-4o-mini"]. A policy no binding uses that named only hosts is left with models: [], which allows every model its pools serve: set its models before you bind it.
  • Links. Each budget link becomes a budgets entry, and every rate-limit and budget link is shared: true. A host key a policy linked directly becomes a pool of that one key, named after the key and owned like it, linked after the policy’s pools; each is logged as 000039: host key "<key>" linked directly by a policy is now pool "<pool>".
  • Payload logging. Each account an enabled binding ties to a policy gets payloadLogging: on where the policy had payloadLoggingEnabled: true, and off where it did not, so the policy’s decision over the keys’ own flags stands. includeDeprecated, payloadLoggingEnabled, credentialIds and budgetIds are removed from every policy.
Migration 39 stops, applies nothing and lists what to fix in three cases:
  • A bound policy names only hosts in models (such as ["@ollama-self"]). Stripping the hosts would leave it allowing every model.
    Fix: replace the references with the providers or models the policy allows (ollama, ollama/llama-4), or unbind the policy.
  • A bound policy pins a host for a provider served at other hosts (such as anthropic/claude-haiku-4-5@anthropic while the provider’s models are also bound at another host the policy can reach), and does not grant the same reference without the host. Dropping the pin would let it reach that provider elsewhere.
    Fix: remove the @host from the reference yourself, or the reference, and after the upgrade link the policy only to pools at that host; or unbind the policy before the upgrade and bind it again once its pools are set.
  • Two bindings reach one account. For example, a binding owned by the system and the user’s own binding on one user both become the user’s personal account, and a team’s binding on a user and a binding of one of its projects on the same user both reach the user’s member account in that project. A 0.11 database cannot reach this case, since its bindings are owned by projects and name a user once per project.
    Fix: remove the subject from all but one of the bindings listed.
Bound policies count with disabled bindings. Step 6 of Before you upgrade finds the first two cases on the 0.11 database. Migration 39 ends with two log lines:

Before you upgrade

  1. Export the current configuration with the 0.11 CLI, as the reference for the rewrite:
  2. Export the links migration 36 drops and the bindings it changes, by name. The query only reads; run it against the 0.11 database and keep the result for Re-creating the dropped links:
    With the query saved as dropped-links.sql:
    Host keys are the rows of the secrets table. For binding -> group and binding -> user rows the models column holds the policy name and project name; these bindings are not dropped but change: group subjects bind nothing after the upgrade, and a user subject becomes the user’s member account in that project. The migration logs the same dropped links as it drops them.
  3. Back up Postgres. Rolling back needs it; see Rolling back.
  4. Find subjects bound twice, which would stop migration 35. Run this against the 0.11 database; it lists what the migration would refuse:
    For each row, decide which binding keeps the subject and remove it from the others, on 0.11, before upgrading.
  5. Check for policies that migration 36 would refuse. A policy with rows in policy_models and an empty models field, or any rows in policy_host_keys (a 0.11 database has no pools or credentials to keep a restriction), would become unrestricted once those tables are dropped; if it is bound to a non-group subject—disabled bindings count—the migration stops. Run this against the 0.11 database to find them:
    For each row, the migration stops and reports: migration 000036: bound policies would lose their only model or host-key restriction and widen; …: policy <name> loses its <what> (bound by <bindings>). Adding models or pools before upgrading is not possible—the pools table exists only in 0.20. On the 0.11 database, delete those bindings or remove those policies from them, then upgrade. After upgrading, add models or pools following Re-creating the dropped links.
  6. Check for policies that migration 39 would refuse: bound policies whose models name only hosts, and pinned references whose provider is served at another host. Run this against the 0.11 database:
    Fix each row on 0.11 with PUT /api/policies/by-id/{id}, as described for each case under Migration 39, or unbind the policy. Callers stay off the inference port until your new pools are linked (step 1 of Upgrade steps), so dropping a pin before the upgrade does not widen live traffic.
  7. Write the new manifests in relay.wyolet.dev/v1alpha3, from the exports; Re-creating the dropped links maps each dropped link to its replacement:
    • An Account for each provider organization, and account on each of its host keys. A key you leave without one stays in the account the upgrade grouped it into.
    • A PolicyBinding of a tier preset (or your own tier policy) on each provider account, replacing the host keys’ policyId. List a host’s presets with GET /api/hosts/{host}/policies.
    • A Pool for each set of host keys a policy listed, with the old keySelection as its selection.
    • Each policy with pools, rateLimits and, where you want spend caps, budgets ([{ref}] entries). models names providers and models, never hosts.
    • A Budget linked by the team’s or project’s policy in place of a team or project budget.
    • Policy bindings on the accounts the keys and service accounts pointed at, and on the team’s or project’s account in place of group and system:authenticated subjects. Bindings name accounts, with the owner in metadata.owner.
    • payloadLogging on the accounts whose traffic you capture, in place of a policy’s payloadLoggingEnabled.
    • No priority on bindings, and no policies or defaultPolicy on hosts.
    Policy examples shows each of these.
  8. Pick the catalog release. Set RELAY_CATALOG_VERSION to the relay-catalog release with the v1alpha3 schema that is published for 0.20. A release in the older schema is refused by 0.20.

Upgrade steps

  1. Keep callers off the inference port until step 5. Between the migration and your new bindings, accounts reach what their scopes own, which may be more than before.
  2. Stop every 0.11 pod: scale the deployment to 0, as in Stop-and-start upgrades.
  3. Deploy 0.20 with the new RELAY_CATALOG_VERSION. Migrations 35 to 39 run, and the catalog release is seeded. Keep the log of the pod that ran the migrations, or of relay migrate up if you migrate in a step of its own: it holds the 000036: dropping link and 000039: lines. A migration that stops applies nothing of itself but leaves its version marked dirty (see Recovering from a dirty schema version):
    • Migration 35 stopped: run relay migrate force 34, start the 0.11 image again, fix the bindings, and repeat.
    • Migration 39 stopped: migrations 35 to 38 are applied, and no image serves that schema. Either run relay migrate force 38, fix the listed rows in Postgres (for example UPDATE policies SET models = '["ollama"]' WHERE name = '<policy>';), and deploy 0.20 again; or restore the backup, fix the rows on 0.11, and repeat the upgrade.
  4. Apply the manifests with the 0.20 CLI, dry run first:
  5. Check representative credentials, then let callers back in:
    Watch relay_inference_auth_rejected_total and the refusal values in the usage events for refusals you did not expect.
  6. Re-run token revocations, see The state store. Tell users whose project access comes through a group or a team-level role to mint new inference tokens.
Each row of the export, and each 000036: dropping link line, has a replacement in 0.20: The catalog’s tier policies, owned by their host, get their rate limits back when the v1alpha3 catalog release is seeded, so their policy → rate limit rows need nothing from you, unless you edited those policies.

A host key’s tier

The upgrade put each host key in an account of its owner at its host, named after the host and marked groupedByRelay. List the accounts with GET /api/accounts?kind=provider and an account’s keys with GET /api/accounts/{ref}/keys. Bind the tier to the account with a binding owned by the system:
An account has one policy. Host keys of one owner at one host share a grouped account, so if they had different tiers, they belong to different provider organizations: create an account for each, name it on its keys, and bind each tier to its own account:
Accounts you create cool on a provider 429, unlike grouped ones; set orgId on a grouped account to make it cool the same way. See Accounts grouped by the upgrade.

A relay key’s policy

A policy now reaches a relay key through the account the key runs in: its service account’s account, or its user’s personal account. Every key in one account has that account’s policy, and an account has one binding, so keys of one principal that pointed at different policies need either one policy for all of them or an account each, such as a service account per use. For a service account’s key, bind the policy to the service account’s account with a binding owned by the service account’s project. The policy may be owned by that project, its team or the system. Keys created without a user before service accounts existed belong to generated service accounts in the legacy project, named legacy-<key name>- and the last 8 characters of the key’s id; their accounts are sa- followed by that name:
For a user’s own key, bind a system-owned policy to the user’s personal account with a binding owned by the system:
A policy a user owns, such as one made for their own automation key, stays theirs: the user binds it to their personal account with a binding they own. It cannot sit beside a system-owned binding of the same account, so use one or the other:
A policy for a user’s work inside a project binds their member account there, alice-project-dev for the user alice in the project dev, with a binding owned by the project. It governs the user’s inference tokens for that project and any relay keys created in that account.

The state store

The Helm chart and the Compose stack bundle Dragonfly as the state store in place of Valkey. Upgrading with the chart’s defaults replaces the bundled Valkey with an empty Dragonfly, and the chart README has the values that keep Valkey. An external store run as Dragonfly needs --cluster_mode=emulated --lock_on_hashtags. An empty store at the cutover means:
  • Control-plane sessions end: users sign in again.
  • Rate-limit windows and key breaker state start fresh.
  • Revoked inference tokens are accepted again until they expire.
Token revocation moves to one list in the store, the {revoked}:tokens key, which 0.11 did not have: tokens revoked one by one under 0.11 are accepted again after the upgrade, whether or not the store changes. Revoke them again with DELETE /api/auth/token/{jti}, or revoke all of a user’s tokens with POST /api/users/by-id/{id}/revoke-tokens, which survives any store change because the user’s token version is kept in Postgres. See Revoking a token. When a 0.20 deployment later moves to another store, copy the revocation list across before switching. The key expires with the last token on it, so it is absent when no revoked token is still valid, and there is nothing to copy:

Rolling back

relay migrate down 34 first reverses migration 39: bindings get subjects back (the account of a team, project or service account becomes that team, project or service account; a personal or member account becomes its user; a provider account stays an account), policies get payloadLoggingEnabled: true back where every account bound to them had payloadLogging: on, and caller accounts, keys’ accountId and the shared flags are dropped, so links counted per lane come back shared. Pools made from directly linked host keys stay pools, and stripped @host suffixes stay stripped. The older schema holds a user once per owner scope, and several accounts of one user (a personal and a member account, or member accounts one binding reached) map back to that user: the oldest binding keeps the user and the others drop it, each logged as 000039 down: binding "<binding>" no longer binds user <id> in the older schema; binding "<keeper>" keeps the user. It then recreates the tables and column migration 36 dropped, empty, and deletes what 0.20 added: accounts, pools, budgets, their links, team-, user- and system-owned bindings, and binding subjects of the new kinds. It does not bring back the policy links migration 36 removed, so a 0.11 pod on that schema runs with no policy links at all. To return to 0.11, stop every pod, restore the Postgres backup taken before the upgrade, and deploy the 0.11 image. See Rolling back for the general procedure.