Status: decided + enforced. Every bex-minted resource id is a typed opaque string of the form <prefix>-<xid> — e.g. srv-c185th5c2rvvnhbfiltg. One package mints and validates them (lego/backend/internal/id), one guard test pins the format, and this ADR is the rationale. The decision this file records: the separator is a hyphen, never an underscore, and ids are minted, never hand-built.
srv-c185th5c2rvvnhbfiltg
└┬┘ └────────┬─────────┘
│ └ 20-char xid: k-sortable (time-ordered), non-guessable, lowercase base32-hex (0-9a-v)
└ 2-4 char type prefix, Render-style
Registered prefixes (the id.Kind registry — Render's public-API spellings, so bex ids are drop-in for Render-shaped clients):
| Prefix | Resource | Render |
|---|---|---|
tea- |
workspace (tenant) | teams |
srv- |
service (app) | srv |
dpg- |
managed Postgres database | dpg |
red- |
managed key-value store | red |
cdm- |
custom domain | cdm |
evm- |
project environment (public alias) | evm |
evt- |
service event (derived) | evt |
crr- |
cron-job run (derived) | — |
Service instances are not a top-level Kind. They are compound ids <service-id>-<opaque> from id.ServiceInstanceID / id.DeriveServiceInstance (see ADR035): the opaque suffix is a name-derived hash so live listing, metrics, and logs agree without exposing Kubernetes pod names. Pre-m87 live SSH selectors hashed the Pod UID; id.MatchServiceInstance still accepts those for the same Ready pod.
Environment discovery and every exposed environment reference use evm-<xid>, matching the pinned Render CLI discriminator. The original env-<xid> remains the durable identity in SQL primary/foreign keys, App/Database/KeyValue labels, environment-group metadata, Blueprint grouping, network policy selectors, and audit storage. No row, resource, or membership is moved or recreated.
id.New(id.Environment) mints the public spelling; the shared grouping insert uses id.EnvironmentStorageID before persistence. The same helper normalizes inputs before ordinary authorization, workspace/project binding, and protection checks. Environment-group create/clone/move normalize before writing metadata. id.EnvironmentPublicID projects IDs in domain views, project membership, resource references, and event placement details; all three transports consume those views. The dashboard also upgrades legacy environment IDs at its two URL search parsers, so saved project and create links retain their selection.
This is a bijection over exact env-/evm- plus 20 lowercase base32-hex characters (0-9a-v), not a name lookup or existence-dependent fallback. Malformed aliases, other resource kinds, and ordinary names are unchanged. ID filters and environment cursors accept either spelling; name filters retain exact name semantics. The SQL environments_storage_id_namespace constraint reserves the entire evm- prefix against insertion. An imported row in that namespace fails the validating migration rather than silently shadowing another identity.
Legacy API links still resolve. An unchanged Render CLI continues to interpret a literal old env-* selector as a name; callers must rediscover the canonical evm-* ID. The environment detail API remains the lookup endpoint; neither the launcher nor upstream CLI is patched.
- Render parity. bex-api is Render-compatible (ADR006-bex-api.md); a Render-targeting client expects
srv-…,tea-…. Matching the prefixes is free compatibility. - DNS-/k8s-name safe → hyphen, not underscore. bex ids flow into URLs, hostnames, and Kubernetes object names. A hyphen is legal in a DNS-1123 label; an underscore is not (RFC 952/1123 forbid
_in hostnames, and k8s object names are DNS-1123). This is the decisive reason bex does not use Stripe'stea_…form — however nice underscores are for double-click-to-select, an underscore id cannot be a hostname or a k8s name. The guard test asserts every minted id is a valid DNS-1123 label, so this can't regress. - Typed + greppable. The prefix says what an id refers to at a glance and in logs (
grep srv-), and lets an adapter route or reject by kind (id.KindOf). - xid, not UUID. xid is k-sortable (embeds a timestamp, so ids sort by creation time — friendly to Postgres b-tree locality), non-guessable, URL-safe (lowercase base32-hex, no padding), and 20 chars (vs UUID's 36 with hyphens). A random UUID is none of typed, sortable, or Render-shaped.
Most ids are minted: id.New(kind) draws a fresh xid, and the id is stored alongside the row it names. A few resources have no row to store an id in — they are projections, computed at read time from rows the store already holds. The service-events feed is the first (ADR006-bex-api.md § Service events): an event is a view over a deploys or audit_events row, never a table of its own.
For those, id.Derive(kind, parts…) mints the id as a deterministic function of the source row or mechanism identity — same parts in, same id out, forever — instead of a fresh xid:
id.Derive(id.Event, "dep-c185th5c2rvvnhbfiltg:started") // → evt-… , identical on every read
id.Derive(id.CronRun, "nightly-run-a1b2c3d4") // → crr-… , identical on every readDeterminism is the requirement, not a nicety: a client pages with an event/run cursor, re-fetches it, and dedupes on its id. id.New would hand out a different id for the same projection on every request, which is exactly wrong. Cron runs use the backing Job name as the derivation input so the ID survives Job deletion without exposing that mechanism name. The output is 100 bits of SHA-256 in base32-hex, so a derived id is shape-identical to a minted one (WellFormed/KindOf accept it and it is a valid DNS-1123 label) — the distinction is where the entropy comes from, not what the id looks like. Derive lives in the same closed registry as New and panics the same way on an unregistered kind, so this is a second mint path, not a bypass of the one place ids are made.
An id and a name are different things and must not be conflated:
- ids (
tea-…/srv-…/dpg-…/red-…) are stable, opaque keys — the primary key in Postgres and the identifier in API URLs. A rename never changes an id, so references never break (ADR009-postgresql-management.md §4). - names (a workspace's name, an app's name) are user-chosen DNS labels (
[a-z0-9-], ≤30 chars) that become part of a CR name (<workspace>-<app>). They're human-facing and mutable.
Render's own workspace names are freeform; bex constrains them to DNS labels because they land in CR/host names. That divergence is noted where it's enforced (internal/workspaces, internal/store/api.go).
Prose rules rot; these don't — the enforcement is layered, compiler first:
- The registry is closed at COMPILE TIME.
id.Kind's fields are unexported, so no code outsideinternal/idcan fabricate a kind —id.Kind{prefix: "zz"}is a compile error ("cannot refer to unexported field"). The only kinds that exist are the package's registered vars (id.Workspace, …); a new one is added there and nowhere else.id.Newadditionally panics fail-fast on the one thing a caller can pass — the zeroKind{}— rather than minting a malformed id. - One mint path.
id.New(kind)is the only way to make an id — nothing concatenates a prefix by hand. The store delegates to it. - Lint forbids the bypass (
lego/backend/.golangci.yml, depguard). Onlyinternal/idmay importgithub.com/rs/xid— the sole source of the random suffix. Import xid anywhere else (i.e. hand-roll an id like"tea-" + xid.New()...) andmake lint-backendfails. This is the layer the compiler and the guard test can't reach: a hand-rolled id at some other call site. Run viamake lint(both modules) ormake lint-backend(backend only). - A guard test (
internal/id/id_test.go, in the spirit ofTestAuthzGuardsEveryVerb) machine-checks, for every registered kind: the format, prefix uniqueness, DNS-1123 label safety, and that the underscore form is rejected. This catches what neither the compiler nor lint can — editing the separator literal inNewfrom-to_: flip it and the suite goes red. - Boundary validation.
id.WellFormed/id.KindOflet adapters reject a malformed id as a 400 and parse a kind back out, instead of string-slicing prefixes ad hoc. This is the only layer that sees runtime id values (from a client, the DB, another service) — lint and the compiler see code, not data.
-
Legacy Postgres and Key Value ids are grandfathered— closed by w1/m56 t009 (2026-07-28): production and maintained dev reported zero non-dpg-/red-ids and zero missing display names. Their CRDs now requirespec.name, readers no longer substitutemetadata.name, and the one-time backfill scripts are gone. A restored pre-normalization snapshot must be upgraded through the preceding release before the current CRD/code is applied. -
API keys carry Hydra's
client_id, not a bex id. OAuth2 clients are minted by Ory Hydra (ADR012-auth.md); their id format is Hydra's, outside this convention by design. -
GraphQL— closed by w1/m46 (2026-07-16): GraphQL now emits the mintedService.idreturns the App namesrv-…id like REST/MCP (internal/apps/graphql.go), and dashboard URLs follow (/services/srv-…). No verb-layer change was needed —core.Base.AuthorizeApp/GetAppalready resolveLabelAppIDfirst with aLabelServiceNamefallback, so name-shaped args and pre-flip URLs keep resolving. Legacy hand-applied CRs withoutLabelAppIDkeep their name as id (publicIDfallback). -
Blueprint ids are
blp-…, where Render's areexs-…(and blueprint-sync runs arebsr-…to Render'sexe-…). Minted with bex's own mnemonic inw2/m15, before the Render OpenAPI compatibility gate existed, so this was never a considered divergence — it was an unrecorded gap, and it had a live cost.internal/api/render_openapi.govalidates every/v1/*request against the embedded copy of Render's spec, which pinsblueprintIdto^exs-[0-9a-z]{20}$; no bex id has ever matched that and none can, so from the day the gate shipped until w6/m96 (2026-08-26) every REST call toGET/PATCH/DELETE /v1/blueprints/{id}andGET /v1/blueprints/{id}/syncswas rejected400 invalid path parameter "blueprintId"before authz or lookup ran. The prefix is not changing — ids are durable and the stored ones areblp-. InsteadrenderPathParameterPatternCompatibilitywidens that one parameter to^(?:blp|exs)-[0-9a-z]{20}$, alongside the file's three existing compatibility maps; a Render-shapedexs-id therefore still passes the gate and gets an honest 404 rather than a syntax error about Render's own spelling.TestEveryConstrainedIDPathParameterAcceptsItsBexIDnow checks every pattern-constrained id path parameter on a bex-served route against the prefix that addresses it, so this class of mismatch fails CI instead of shipping silently — the other four in the intersected surface (dsk-,whk-,evt-,job-) all match Render's patterns today and are locked in by that test. Auto-sync intents areasi-…(w8/m38) — bex-native durable acceptance rows; Render exposes no public intent id.The sync id (
bsr-vs Render'sexe-) is unverified rather than confirmed safe: no path parameter in the currently-embedded spec is constrained to a sync id — sync runs appear only in response bodies, never as an addressable path segment — so the same gate cannot currently reject one. If a future spec refresh introduces such a parameter on a route bex serves, the guard test above fails and this entry is where to record the outcome. -
REST and GraphQL
Domain.idreturn the hostname, not the mintedcdm-…id. Deliberate: domains are addressed by hostname in most Render flows (DNS instructions, the custom domain itself), making the hostname the honest identity contract. Both REST (internal/apps/domains.go::toRenderCustomDomain) and GraphQL (internal/apps/graphql.go::customDomainGQLType) emitd.Nameconsistently — no surface divergence.
- Underscore (
tea_…, Stripe/GitHub style). Rejected: illegal in hostnames and k8s names, and off-parity with Render. Its one real win (double-click-to-select) doesn't outweigh losing DNS-safety. - Bare UUIDs. Rejected: untyped, not k-sortable, longer, not Render-shaped.
- Sequential integer ids. Rejected: guessable/enumerable (an information leak in URLs) and leaks row counts.
A Render create or Blueprint takes a free-form service name; bex derives the resource name (the slug in the host and the workspace-unique key) with blueprintResourceSlug (lego/backend/internal/apps/blueprint_names.go), mirrored by the dashboard's resource-slug.ts. The mapping lowercases, folds accented Latin letters onto their base letters (NFKD, combining marks dropped, so Ünï is uni), turns every other run into one hyphen, and keeps the start plus a short sha256 of the name when the result passes 30 characters. A name with nothing left in a–z/0–9 (Chinese, Japanese, Cyrillic, Arabic, emoji) takes resource-<6 hex of sha256(name)>: stable per name and distinct between names. Before this, every such name took the one fixed name resource, so a workspace could hold one, and accents were dropped. A conflict names the typed name as well as the resource name it maps to. Services created under the old mapping keep their name; the Blueprint plan finds them through their display name (workspaceSnapshot.services aliases the legacy name), so a re-sync updates them instead of creating a second service.