Replace the entity slugs on organizations, workspaces, resource pools, and plan ladders with nullable `key` columns and add keys to products, prices, and entitlement sets. Rename `providers.slug` to `provider` and add partial unique indexes for system and org role names. Assign invoice numbers per billing account from a gapless transactional counter; Stripe's number moves to the invoice mapping as an external reference. Seeds, fixtures, and the operator lookup address rows by key, and the returning-login resync no longer blanks a display name when the IdP sends no `name` claim.
432 lines
55 KiB
Markdown
432 lines
55 KiB
Markdown
# Naming and Identifying Entities: A Pattern for Application Data Models
|
||
|
||
**A portable design pattern.** This document is self-contained. It assumes no knowledge of any particular product, schema, or team, and it can be adopted into any application that stores entities in a relational database and exposes them to people and to other systems.
|
||
|
||
**Version 1.0 — 2026-08-29.**
|
||
|
||
Every factual claim about an external system carries a URL. Pages were retrieved and read; nothing is cited from memory. Where a claim could not be established from a primary source, it is marked *not verified* rather than asserted.
|
||
|
||
---
|
||
|
||
## 1. The problem this pattern solves
|
||
|
||
Every entity in an application eventually acquires several identifying strings. A user record has a primary key, a display name, a login, an email address, and perhaps a URL fragment. A catalog item has a key, a title, and an identifier in a payment processor. These accumulate one at a time, each added for a local reason, and nobody ever writes down what distinguishes them.
|
||
|
||
The symptoms of not having written it down are specific and recognisable:
|
||
|
||
- A column exists whose only consumer is the test seed, but it now appears in the API and cannot be removed.
|
||
- Renaming an entity changes what the audit log appears to say happened last year.
|
||
- A "permanent" identifier is displayed beside a display name that has drifted away from it, and users find the pair confusing.
|
||
- A form asks a person for a name and then for a URL-safe version of the same name, and the second answer is worse than the first.
|
||
- Two identifiers both look canonical, so different subsystems store different ones, and reconciling them becomes a project.
|
||
- A value derived from an authentication provider — a login name, an email local part — is quietly persisted into an identifier column, where nobody can see it and no one can correct it.
|
||
|
||
Each of these follows from the same root cause: **two different jobs were given to one string.** The pattern below separates the jobs, states which columns each job justifies, and gives a test for deciding whether a new column is warranted.
|
||
|
||
---
|
||
|
||
## 2. Five roles, and one impostor
|
||
|
||
There are exactly five jobs an identifying string can do. They differ in who assigns the value, whether it may change, and what breaks when it does.
|
||
|
||
| Role | What it is | Assigned by | Mutable? | Unique? |
|
||
|---|---|---|---|---|
|
||
| **Identity** | The value other records store. The answer to "which row is this, across all of time." | The system | Never | Always, globally |
|
||
| **Name** | The label a person reads on a screen. | A person | Freely | No, by default |
|
||
| **Key** | A stable string that source code, configuration files, seeds, or another system writes down literally. | Whoever owns the vocabulary | Rarely, and the old value is *freed* — nothing resolves an old key lazily, so there is no redirect to preserve | Within the smallest namespace it inhabits |
|
||
| **Handle** | A short, human-readable identifier a person types or says aloud to *reach* the entity. | A person, at creation | Rarely, and the old value is retired rather than recycled | Within its addressing scope |
|
||
| **External reference** | The identifier some other system uses for the same real-world thing. | The other system | By the other system | Within that system |
|
||
|
||
And the impostor — the thing most often mistaken for a handle:
|
||
|
||
| **Reference number** | A short token a person quotes to another person to name *a document*. "Invoice 0042." | The system, sequentially | Never | Within a tenant |
|
||
|
||
**The distinction between a handle and a reference number is the one most designs miss.** A handle is an *address*: you can navigate with it. A reference number is a *citation*: you quote it to somebody who then finds the thing by other means. Support conversations, invoices, and correspondence need reference numbers. A design that reasons "nobody navigates by invoice number, therefore invoices need no human-readable identifier" has confused the two and will ship a system where a support agent and a customer cannot establish which invoice they are discussing.
|
||
|
||
---
|
||
|
||
## 3. The governing rule
|
||
|
||
> **Identity is opaque and permanent. Presentation is a name and freely mutable. Every additional identifier column must name the consumer that forced it into existence.**
|
||
|
||
The three clauses are defended in turn.
|
||
|
||
### 3.1 Identity is opaque and permanent
|
||
|
||
Use a system-generated opaque identifier — a UUID, or a prefixed opaque string in the manner of Stripe's `price_1MoBy5LkdIwHu7ixZhnattbh` — as the primary key, and let it be the only identity.
|
||
|
||
**Because an entity should have exactly one canonical identifier.** Google's API Improvement Proposal 2510 states the principle and then documents the cost of having violated it. Google Cloud projects carry three identifiers because two systems merged: "API projects used numbers … App Engine projects used IDs," and after convergence "each project has *both* unique and immutable identifiers." The AIP's own first rationale bullet is "Each resource should always have one canonical identifier," and the consequences of having two are spelled out at length: services "should return whichever identifier the user sent," and "error responses **must** return the originally-provided value without modification," because auto-translating between the two "has proven to cause real-world difficulty for users, and also for declarative tools" (https://google.aip.dev/cloud/2510).
|
||
|
||
**Because identity must survive everything presentation can do.** Tim Berners-Lee's "Cool URIs don't change" makes the general argument: "After the creation date, putting any information in the name is asking for trouble one way or another," and it enumerates what people put in names and later regret — author, subject, status, access level, file extension. On classification in particular: "when you use a topic name in a URI you are binding yourself to some classification. You may in the future prefer a different one. Then, the URI will be liable to break" (https://www.w3.org/Provider/Style/URI). An opaque identifier contains no assertion that can become false.
|
||
|
||
**Because identity must distinguish historical occurrences, not merely current ones.** Kubernetes draws this line explicitly. A name is unique only "at a time" — "if you delete the object, you can make a new object with the same name" — whereas the UID is "intended to distinguish between historical occurrences of similar entities," and "every object created over the whole lifetime of a Kubernetes cluster has a distinct UID" (https://kubernetes.io/docs/concepts/overview/working-with-objects/names/). Any system with an audit log, a ledger, or a retention obligation is making claims about historical occurrences, and only a never-recycled identifier can carry them.
|
||
|
||
**A corollary that must be enforced, not assumed:** identity values are never reused. If your delete is a hard delete and your identifier is a recyclable string, you do not have identity in the sense this pattern requires.
|
||
|
||
### 3.2 Presentation is a name, and names may collide
|
||
|
||
Make the display name mutable, user-settable, and non-unique.
|
||
|
||
The guidance is unanimous across the sources that address it. AIP-148: `display_name` "**must** be a mutable, user-settable field where the user can provide a human-readable name to be used in user interfaces… Display names **should not** have uniqueness requirements, and **should** be limited to <= 63 characters" (https://google.aip.dev/148). Google Cloud on project names: "The project name isn't used by any Google APIs. You can edit the project name at any time during or after project creation. Project names don't need to be unique" (https://cloud.google.com/resource-manager/docs/creating-managing-projects). Stripe's product `name` is "meant to be displayable to the customer," with no uniqueness documented on the object (https://docs.stripe.com/api/products/object).
|
||
|
||
Systems that *do* enforce unique names — GitHub repositories within an owner, Kubernetes objects within a namespace, Heroku applications globally — do so because in those systems the name **is** the address. They have no separate display field to carry the human label. If your entity has both a name and an opaque identity, the name is not the address, and making it unique is borrowing a constraint from a design you did not adopt.
|
||
|
||
**The one admissible exception is a UX guard**: uniqueness imposed on a name because two identically named siblings would be indistinguishable in a list, where nothing about identity depends on the constraint. A UX guard has three obligatory properties. A constraint missing any of them is an identity mechanism wearing a disguise.
|
||
|
||
1. **Scoped to a parent, never global.** Global name uniqueness makes the name a system-wide address.
|
||
2. **Case-insensitive.** `Production` and `production` are the same name to the person you are protecting.
|
||
3. **Scoped to live rows.** A soft-deleted sibling must release its name, or your users will eventually be unable to reuse a name nothing is using. In PostgreSQL this is a partial unique index: `CREATE UNIQUE INDEX … ON t (parent_id, lower(name)) WHERE status <> 'deleted'`.
|
||
|
||
### 3.3 Every other identifier must name its consumer
|
||
|
||
This is the operative rule, and the one that stops columns accumulating.
|
||
|
||
**Add a key or handle column if and only if at least one of these is demonstrably true, and record which one beside the column:**
|
||
|
||
- **C1 — Source code names the value literally.** A branch, a generated role name, a directory path, or a template prefix contains the string. A UUID here would force every code site to indirect through a lookup for no benefit.
|
||
- **C2 — A person says or types it.** It is spoken in a support call, read aloud, typed into an address bar, or printed on paper.
|
||
- **C3 — Another row or another system stores it as a reference.** It is a foreign-key target across a boundary that cannot carry your opaque identity, or an external system persists it.
|
||
- **C4 — Declarative configuration outside the database names it.** A config file, deployment manifest, or runbook references the entity by string, and that reference must survive a rename.
|
||
|
||
**And one criterion that is deliberately excluded:**
|
||
|
||
- **C5 — "A seed, fixture, or test needs to find the row." This justifies a column only if your project forbids client-supplied identifiers.** Section 8 sets out both routes and the precondition that decides between them. Where a client may supply the identifier, the determinism can come from the primary key itself and a column added for the test harness is a permanent public surface bought for nothing. Where identifiers must be generated by the database, the caller has no way to compute one, and the key is the only remaining address — the seed is then simply code naming a row literally, which is C1. **Settle this precondition before you write the rest of the policy**; getting it wrong invalidates the seeding section and everything that leans on it.
|
||
|
||
If none of C1–C4 holds, the entity carries an opaque identity and a name, and nothing else. **The absence of a handle is not a gap.** It is the correct state until a consumer appears, and the test tells you when that has happened.
|
||
|
||
---
|
||
|
||
## 4. Entity classes
|
||
|
||
Classifying an entity first answers most identifier questions without further argument. Six classes suffice for typical application models.
|
||
|
||
| Class | What belongs here | Carries |
|
||
|---|---|---|
|
||
| **A — Tenants and actors** | Things that act or own other things: accounts, users, teams, projects, workspaces | Identity + name. Key (nullable) where identifiers are database-generated (§8.2 Route B). Handle only under C2. |
|
||
| **B — Catalog and configuration** | Things an operator authors that shape behaviour: products, prices, plans, roles, policies | Identity + name. Key (nullable) — under C4 on Route A, by class on Route B (§8.6). |
|
||
| **C — Vocabularies** | Small closed sets whose values appear verbatim in source code: capability keys, provider registries, type enumerations | **The key is the primary key.** The deliberate exception, earned by C1. |
|
||
| **D — Ledgers and events** | Immutable records of things that happened: audit logs, usage events, state transitions, outboxes | Identity + **snapshot labels** (§7). Never a handle, never a name of its own. |
|
||
| **E — Relationships** | Rows whose meaning is the pair they join: memberships, assignments, line items | Identity only. Identified by their endpoints. |
|
||
| **F — Documents** | Records a person cites to another person, often outside the system: invoices, receipts, orders, claims | Identity + **reference number**. |
|
||
|
||
**The boundary most often argued is B against C**, and the discriminator is sharp: **a vocabulary's values are enumerated in your source code; a catalog's values are authored at runtime by an operator.** A product named "Pro" is catalog — no code says `"Pro"`. A capability key named `seats` is vocabulary — the metering code says `seats`. Get this wrong in the C direction and you have a user-editable string that code branches on; get it wrong in the B direction and you have a lookup table for values nobody enumerates.
|
||
|
||
**Class E's naming prohibition has one carve-out worth stating**, because it will otherwise be read as stricter than intended. A relationship row may carry a plain non-unique display label where a person must distinguish several of them in a list — API tokens are the canonical case, since a user holds five and must decide which to revoke. What it may not carry is a *unique* name, which would make the relationship addressable and it is not.
|
||
|
||
**Class D's prohibition has no carve-out.** A ledger row is not a thing anyone refers to by name.
|
||
|
||
**One rule cuts across the table: where an external system's identifier already uniquely names every row, that column *is* the key and no second one is added.** An authentication provider's subject claim keys a user; a hostname keys a hosted site; a payment processor's own identifier keys a mapping row. Adding a local key beside one of these gives the row two addresses and invites them to disagree.
|
||
|
||
---
|
||
|
||
## 5. Assignment, mutability, and scope
|
||
|
||
### 5.1 Who assigns, and when
|
||
|
||
| Role | Assigned by | When |
|
||
|---|---|---|
|
||
| Identity | The system | At insert |
|
||
| Name | The creating person | At creation; editable forever |
|
||
| Key (vocabulary) | A migration or boot-time registration | At definition |
|
||
| Key (catalog, under C4) | The operator, **derived live from the name**, editable until creation commits | At creation only |
|
||
| Handle (under C2) | The person, validated against a documented format | At creation |
|
||
| Reference number | The system, sequentially within a tenant | At document issuance |
|
||
|
||
**Derive, do not ask twice.** When an operator must supply a key, generate it from the name they have already typed and let them edit the generated value before committing. Google Cloud does exactly this: "After you enter a project name, the Google Cloud console generates a unique project ID… Use the generated project ID, but you can edit it during project creation" (https://cloud.google.com/resource-manager/docs/creating-managing-projects). WordPress does it continuously in the editor: the permalink "is automatically generated based on the title you set to the post and is shown below the title field," then remains editable (https://wordpress.org/documentation/article/write-posts-classic-editor/). Discourse goes further and regenerates the topic slug whenever the title changes; a staff member confirms this is deliberate: "the redirect and slug updates aren't a fluke, it was engineered to behave this way" (https://meta.discourse.org/t/seo-issue-slug-names-for-topics-should-never-change-on-title-changes/162580).
|
||
|
||
A form that asks separately for a name and a URL-safe name is asking one question twice, and the second answer is the one the operator will get wrong.
|
||
|
||
### 5.2 Mutability, and the recycling trap
|
||
|
||
**A key never changes.** Immutability is what makes it a key. If it could change, the configuration and code that name it literally would break — which is the only reason it exists.
|
||
|
||
**A handle may change, but the old value is retired, never recycled.** This is the single most important operational rule in the pattern, and the evidence for it is that almost everyone gets it wrong.
|
||
|
||
Four widely used systems free the old handle for reuse, and every one of them documents the freeing as the mechanism that destroys their own redirects:
|
||
|
||
- **GitHub:** "After changing your username, your old username becomes available for anyone else to claim," and "if the new owner of your old username creates a repository with the same name as your repository, that will override the redirect entry and your redirect will stop working" (https://docs.github.com/en/account-and-profile/setting-up-and-managing-your-personal-account-on-github/managing-user-account-settings/changing-your-github-username).
|
||
- **GitLab:** "the redirects are available as long as the original path is not claimed by another group, user, or project" (https://docs.gitlab.com/user/project/working_with_projects/).
|
||
- **Slack:** "Your old workspace URL will become available for use by another group. Slack will automatically redirect the old address to the new one as long as it's not claimed" (https://slack.com/help/articles/201663443-Change-your-workspace-or-org-name-and-URL).
|
||
- **Zulip:** "By default, the old subdomain may be freed up for reuse after 3 months" (https://zulip.com/help/change-organization-url).
|
||
|
||
Exactly one surveyed system does the opposite, and it is the one to copy. Jira: "You won't be able to create a new space with the old space key. The old space key will only be available if you delete the space it was previously associated with," and "Links containing the old space key will continue to work, since link aliases won't be updated with the new key" (https://support.atlassian.com/jira-work-management/docs/edit-a-projects-details/).
|
||
|
||
Recycling is only worth its failure mode under genuine namespace pressure — a global namespace of short strings with millions of contenders. Most applications have no such pressure and should simply retire old handles permanently.
|
||
|
||
**Note also that redirects do not cover everything, and the vendors say so.** Rename side effects that no redirect fixes are documented by seven of the surveyed products: GitHub Actions references, GitHub Pages URLs, `@mentions` and gists; GitLab CI `include:` statements, encoded-path API calls, Docker image references and `CODEOWNERS`; Slack's SSO configuration; Sentry's integration links, `SENTRY_ORG` variable, and SCIM/SAML configuration; Zulip's API clients; Jira's board filters and dashboard gadgets; and, most bluntly, Notion — switching to a new site domain means existing links "will no longer work!" (https://www.notion.com/help/manage-your-notion-sites). **Plan the rename story before you ship the handle, not after the first support ticket.**
|
||
|
||
### 5.3 When to freeze a handle — and the lesson from Google Cloud project IDs
|
||
|
||
Freezing a human-readable identifier is justified when it has been copied into places you do not control. Google Cloud freezes the project ID because it "appears in the name of many other Google Cloud resources" — bucket names, log entries, billing records — and states plainly: "After project creation, the project ID is permanent," with the additional rule that it "cannot be in use or previously used; this includes deleted projects" (https://cloud.google.com/resource-manager/docs/creating-managing-projects). Shopify permits changing the `myshopify.com` domain "only 1 time," because third-party apps use it as the account identifier (https://help.shopify.com/en/manual/domains). Nextcloud's login name "cannot be changed" and doubles as the user's identity (https://docs.nextcloud.com/server/latest/admin_manual/configuration_user/user_configuration.html).
|
||
|
||
But frozen human-readable identifiers have a specific failure mode, and it is worth naming because it is the most common complaint about the GCP design: **the frozen identifier and the mutable display name drift apart, and users find the pair confusing.** You name a project "Staging", the console derives the ID `staging-482913`, you later rename the project to "Integration", and now the console shows "Integration" while every log line, bucket, and billing record says `staging-482913`.
|
||
|
||
The correct conclusion is not that permanence is wrong. It is:
|
||
|
||
> **Never freeze a string that is also a display surface.** Freeze what machines cite; let humans rename what humans read.
|
||
|
||
An opaque identity cannot be misread as a name, so nobody is surprised when it does not change. A frozen identifier shaped like a name invites the reader to treat it as one. If you must expose a frozen human-readable key, present it as a key — labelled, muted, with a one-line disclosure that it is permanent and why — rather than as a second name competing with the first.
|
||
|
||
### 5.4 Uniqueness scope
|
||
|
||
Scope uniqueness to the smallest container in which ambiguity would actually harm someone. Surveyed practice splits roughly evenly between global and per-parent, which tells you the choice is contextual rather than conventional: global for GitHub usernames, GCP project IDs, Heroku and Fly.io app names, Slack workspace URLs and Bluesky handles; per-parent for GitHub repositories (per owner), GitLab paths (per namespace), Sentry projects (per organization), Kubernetes names (per namespace) and Mastodon usernames (per server — "the same username *can* be registered on different servers," https://docs.joinmastodon.org/user/signup/).
|
||
|
||
Defaults that follow from the roles:
|
||
|
||
- **Vocabulary keys:** global within the vocabulary, with third-party entries namespaced by their owner's prefix so collisions are structurally impossible.
|
||
- **Catalog keys:** global, because configuration files have no parent scope to inherit.
|
||
- **Handles:** scoped to the addressing surface. A hostname is global because DNS is global; a project handle inside an account is per-account.
|
||
- **Name UX guards:** per parent, case-insensitive, live rows only.
|
||
- **Reference numbers:** per tenant, so two customers may both hold an invoice 0042.
|
||
|
||
---
|
||
|
||
## 6. Addressing: URLs and APIs
|
||
|
||
**Opaque identifiers are sufficient for an API, and this is not a controversial position.**
|
||
|
||
- Stripe ships only prefixed opaque IDs on Products and Prices; `lookup_key` is an optional extra defaulting to null (https://docs.stripe.com/api/prices/object).
|
||
- AIP-133 permits system-generated IDs as the fallback and prints `publishers/012345678-abcd-cdef/books/12341234-5678-abcd` as a legitimate resource name (https://google.aip.dev/133).
|
||
- AIP-122 removed its own former prohibition on UUID-shaped user-specified IDs, stating: "we no longer saw value in this requirement, hence its removal" (https://google.aip.dev/122).
|
||
- Heroku accepts either form in the same path parameter and states a preference: "Though the human-friendly version may be more convenient, `id` should be preferred to avoid ambiguity" (https://devcenter.heroku.com/articles/platform-api-reference).
|
||
|
||
**The costs of opaque identifiers in URLs are real and should be acknowledged rather than dismissed.** Zalando's rule 144 enumerates them: a UUID is a "pure technical key without meaning"; it "cannot be memorized and easily communicated by humans"; it is "harder to use in debugging and logging analysis"; its readable form "requires 36 characters"; and UUIDs are "not ordered along their creation history" (https://opensource.zalando.com/restful-api-guidelines/). Google Search Central prefers "readable words rather than long ID numbers" for indexable pages (https://developers.google.com/search/docs/crawling-indexing/url-structure).
|
||
|
||
**Weigh those costs against your actual surface.** Memorability and debuggability matter for identifiers humans handle; they matter much less for links that are clicked inside an authenticated application. Search-engine readability matters only for pages a search engine will index. If you have no public indexable pages, the SEO argument is not evidence for anything you should build.
|
||
|
||
**Two rules apply regardless.**
|
||
|
||
*A time-ordered UUID discloses creation time and ordering.* RFC 9562 §5.7 defines UUIDv7's first 48 bits as "a 48-bit big-endian unsigned number of the Unix Epoch timestamp in milliseconds," so anyone holding the identifier learns when the record was created and how two records are ordered (https://www.rfc-editor.org/rfc/rfc9562.txt). RFC 9562 §8 characterises this as "a very small attack surface" that "does not define anything about the data itself," and recommends UUIDv4 "If UUIDs are required for use with any security operation." Decide deliberately which version you are using and why.
|
||
|
||
*An identifier in a URL is never an authorization decision.* RFC 9562 §8 is categorical: implementations "SHOULD NOT assume that UUIDs are hard to guess. For example, they MUST NOT be used as security capabilities (identifiers whose mere possession grants access)." Authorize every request on its own terms.
|
||
|
||
**If you later want human-readable addressing, adopt the documented shape rather than exposing whatever column is at hand.** AIP-133 requires that "An API **must** allow a user to specify the ID component of a resource … on creation if the API is operating on the management plane," with the ID field on the request message rather than the resource, and permits it to be optional with a system-generated fallback. Grafana's dashboard API implements exactly this: a client may set `metadata.name` as the dashboard's identifier, or set `metadata.generateName` to a prefix "you would like for the randomly generated uid" (https://grafana.com/docs/grafana/latest/developer-resources/api-reference/http-api/dashboard.md).
|
||
|
||
**And if a resource can be renamed, it needs a stable-identifier URL in addition to any friendly one.** Microsoft's REST guidelines: "In addition to friendly URLs, resources that can be moved or be renamed SHOULD expose a URL that contains a unique stable identifier," adding that "The stable identifier is not required to be a GUID" (https://raw.githubusercontent.com/microsoft/api-guidelines/vNext/graph/Guidelines-deprecated.md). GitLab demonstrates the mechanism: a renamed project's old path resolves via a `Location` header to the numeric-ID form (https://docs.gitlab.com/api/rest/). Note the direction of the fallback — **the stable-ID URL is the half that must exist**; the friendly URL is the optional addition.
|
||
|
||
---
|
||
|
||
## 7. History and audit citation
|
||
|
||
This is where designs most often ship a defect that nobody notices until the first rename.
|
||
|
||
**The principle,** stated by Kubernetes: a UID is "intended to distinguish between historical occurrences of similar entities," while a name identifies what exists now (https://kubernetes.io/docs/concepts/overview/working-with-objects/names/). An audit entry, a ledger row, and a state-transition record are all historical occurrences.
|
||
|
||
**The defect:** if a history row displays a label resolved live from the entity's current state, then renaming the entity *rewrites the past as the reader sees it*. Worse, if the row stores a positional reference — a rank, an index, an ordinal — reordering the parent silently repoints it, and no amount of live resolution can recover what happened.
|
||
|
||
**The rule:**
|
||
|
||
> A history or audit row cites the opaque identity for identity, and stores a **snapshot of the label as it stood at write time** for legibility. Render the snapshot; disclose the current value where it differs.
|
||
|
||
Live resolution is correct only where the row is a *view of current state*, never where it is a *record of a past event*.
|
||
|
||
Most systems already apply this discipline somewhere without generalising it: an invoice that snapshots the customer's billing name and address at issuance rather than joining to the customer record is doing exactly this, for exactly this reason. An invoice must say what was true when it was issued. So must an audit log.
|
||
|
||
**Two cheaper alternatives exist, and both are worse.** Rendering the immutable key beside the name identifies the entity across renames but only for entities that have keys, and it puts machine strings on a human surface. Resolving live and disclosing the fact in a tooltip is honest about being wrong, and is still wrong after the first rename. Snapshotting is the only design that makes the history true.
|
||
|
||
---
|
||
|
||
## 8. Deterministic seeding and testing without a key column
|
||
|
||
This section addresses the objection that most often produces unnecessary identifier columns.
|
||
|
||
### 8.1 The apparent dilemma
|
||
|
||
A seed or test fixture must be able to (a) create a row, (b) know its identifier without querying, so other fixtures can reference it, and (c) recognise on a later run that the row already exists, so re-running is idempotent.
|
||
|
||
With sequential integer keys, fixtures hardcode small integers and all three properties hold trivially. With opaque identifiers the seed appears to face a forced choice:
|
||
|
||
- **Hardcode identifier literals in the seed.** This works, but the literals are unreadable, a human must generate them out of band, and it discards the property that motivated opaque identifiers in the first place — the values are now a hand-maintained fixed list.
|
||
- **Look the row up by some other deterministic field.** This works, but if no such field exists for a product reason, one gets added *for the seed*, and now the schema carries a permanent column whose only consumer is the test harness. This is criterion C5, and it is the single most common way identifier columns get created for no product reason.
|
||
|
||
Django's documentation names the underlying constraint precisely, discussing fixtures that refer to a foreign key by primary key: "It requires that you know the primary key value for the author; it also requires that this primary key value is stable and predictable" (https://docs.djangoproject.com/en/5.2/topics/serialization/).
|
||
|
||
### 8.2 First settle the precondition: may a client supply an identifier?
|
||
|
||
Everything below depends on one question about your project, and it is worth answering explicitly before choosing a route, because the two routes are not interchangeable.
|
||
|
||
**Route A applies when a client may supply the identifier at creation.** Then the seed can compute the identifier itself and no column is needed (§8.3–§8.5).
|
||
|
||
**Route B applies when identifiers must be generated by the database** — a rule many projects adopt deliberately, for non-predictability, for merge safety across instances, or so that a time-ordered identifier keeps index locality on insert. Under that rule an external caller cannot compute, supply, or pin an identifier, so **Route A is unavailable and the row needs a key**: a nullable unique string by which configuration, seeds, and clients address it. This is not a defeat. It is what Kubernetes does (`metadata.uid` server-assigned, `metadata.name` the address, `kubectl apply` idempotent by name), what Stripe does (`price_…` generated, `lookup_key` for static reference), what Django's natural keys do, and what Keycloak import does (match on `username` and `clientId`). The seed then inserts `ON CONFLICT (key) DO NOTHING` and reads the generated identifier back.
|
||
|
||
A rule worth adopting on either route, and easy to miss: **a seed ensures existence; it does not update an existing row's fields.** If keys are reassignable, a seed naming a key it no longer owns lands on whatever row now holds it — that is what reassignment means. Ensuring existence makes the misdirection harmless; `DO UPDATE` would silently overwrite an operator's edits on a row the seed was never meant to touch, with no error to make it visible. The exception is a declarative loader whose file is the source of truth (the `kubectl apply` posture), which may update and must say so, because it is claiming ownership of every field it writes.
|
||
|
||
The failure to avoid is choosing Route A *under a rule that forbids it* and recording the conflict as an accepted exception. A rule about identifier generation is usually load-bearing for reasons the seeding discussion cannot see, and it is not a policy document's to except. If the two collide, that is a decision for whoever owns the rule.
|
||
|
||
The rest of this section is Route A. If you are on Route B, apply the key contract in §4's terms — assigned by class, nullable, unique within the smallest inhabited namespace, mutable with the old value freed — and note that a key differs from a handle precisely in that nothing resolves an old key lazily, so there is no redirect to preserve.
|
||
|
||
### 8.3 Route A: derive the identity from a label
|
||
|
||
**Compute the identifier deterministically from a namespace plus a human-readable label.**
|
||
|
||
RFC 9562 §5.5 defines UUID version 5: "UUIDv5 values are created by computing an SHA-1 hash over a given Namespace ID value concatenated with the desired name value after both have been converted to a canonical sequence of octets" (https://www.rfc-editor.org/rfc/rfc9562.txt). §6.5 states the guarantee normatively:
|
||
|
||
> "UUIDs generated at different times from the same name (using the same canonical format) in the same namespace **MUST** be equal."
|
||
>
|
||
> "If two UUIDs that were generated from names (using the same canonical format) are equal, then they were generated from the same name in the same namespace (with very high probability)."
|
||
|
||
**Rails has shipped exactly this for fixtures for years.** `ActiveRecord::FixtureSet.identify(label, column_type)` returns "a consistent, platform-independent identifier for `label`", where "UUIDs are RFC 4122 version 5 SHA-1 hashes," implemented as `Digest::UUID.uuid_v5(Digest::UUID::OID_NAMESPACE, label.to_s)`. The documented consequence is the one that matters here (https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html):
|
||
|
||
> "The generated ID for a given label is constant, so we can discover any fixture's ID without loading anything, as long as we know the label."
|
||
|
||
In practice a fixture names a record `george`, never writes an identifier, and any other fixture resolves george's primary key from the string `"george"` alone. Rails extends the same mechanism to composite primary keys through `composite_identify(label, key)`.
|
||
|
||
### 8.4 Why this dissolves the dilemma
|
||
|
||
The human-readable label — `demo-plan`, `acme-account`, `seed-default-pool` — is what the seed author reads and writes. **It lives in the seed file. It is not a column.** The database stores only an opaque identifier, indistinguishable in kind from any other row's. Therefore:
|
||
|
||
- **(a) Creating a row:** the seed computes the identifier before inserting and supplies it explicitly.
|
||
- **(b) Knowing it without querying:** any fixture computes any other fixture's identifier from its label, so foreign keys are written by label and resolved locally — no hardcoded literals and no lookup round trip.
|
||
- **(c) Idempotent re-runs:** `INSERT … ON CONFLICT (pk) DO UPDATE` makes re-seeding a refresh, or `DO NOTHING` makes it a no-op. This needs **no additional unique index**, because the primary key is already an arbiter: PostgreSQL infers arbiter indexes from the conflict target, and "a non-partial unique index (a unique index without a predicate) will be inferred (and thus used by `ON CONFLICT`) if such an index satisfying every other criteria is available" (https://www.postgresql.org/docs/current/sql-insert.html).
|
||
|
||
The seed obtains everything it needed. The schema gains nothing it did not need. **That is why "a seed needs to find the row" is excluded from the consumer test.**
|
||
|
||
### 8.5 Conditions and honest limits
|
||
|
||
Seven constraints must hold. State them all wherever the technique is used.
|
||
|
||
1. **The namespace is a checked-in constant, generated once, never regenerated.** Every derived identifier depends on it; changing it silently changes every seeded identity.
|
||
2. **Labels are canonicalised.** RFC 9562 §6.5 conditions the equality guarantee on "the same canonical format" and warns that "each name format within a namespace will output different UUIDs." Fix one form — lowercase, trimmed, one separator — and enforce it inside the helper, not at each call site.
|
||
3. **A version-5 UUID is not a version-7 UUID.** If you standardise on UUIDv7 for index locality, seeded rows will carry version-5 values that are not time-ordered and will not cluster on insert. For a small fixed set of seeded rows the cost is negligible, but it is a deliberate exception that must be documented rather than discovered later as an anomaly.
|
||
|
||
4. **Resist the SHA-256 upgrade unless you are prepared to specify it yourself.** RFC 9562 §5.5 does say that where SHA-1 "may not be available or may be deemed unsafe for use," name-based UUIDs from SHA-256 "MUST NOT utilize UUIDv5 and MUST be within the UUIDv8 space defined by Section 5.8." Follow that sentence carelessly and you lose interoperability, because **UUIDv8 is not an algorithm the way UUIDv5 is.** §5.8: "UUIDv8's uniqueness will be implementation specific and MUST NOT be assumed. The only explicitly defined bits are those of the version and variant fields, leaving 122 bits for implementation-specific UUIDs." And Appendix B, which contains the SHA-256 example you would naturally reach for, warns that its examples have "not been through the same rigorous testing, prototyping, and feedback loop that other algorithms in this document have undergone" and that "the authors encourage implementers to create their own UUIDv8 algorithm rather than use the items defined in this section."
|
||
|
||
The practical consequence: `uuid5(namespace, name)` is a complete specification that any library in any language satisfies, whereas "v8 over SHA-256" is not — two independent implementations will produce different identifiers for the same label unless they share a bit-level construction. Since the whole point of computed identity is that separate codebases agree *without reading each other's output*, that asymmetry usually decides it in favour of v5. The SHA-1 objection carries little weight in this application: the input is a non-secret label, no adversary chooses labels, and no security property rests on collision resistance. If you do choose v8, you own the specification — pin which digest bytes populate the 128 bits, the byte order, where the version and variant bits are stamped, and the input's canonical encoding.
|
||
|
||
5. **Publish test vectors, whichever construction you choose.** A handful of label-to-identifier pairs in the contract document lets any future implementation prove agreement in a unit test instead of in production. This is the cheapest possible insurance against a silent divergence, whose symptom is not a hash mismatch but duplicate rows appearing where the seed expected to find existing ones.
|
||
6. **Apply it only to rows the seed owns.** Never derive a production row's identifier from user-supplied data: it makes the identifier guessable by anyone who can guess the input, and RFC 9562 §8 warns that implementations "SHOULD NOT assume that UUIDs are hard to guess." Deriving an account's identifier from its name hands an attacker every account identifier.
|
||
7. **It does not replace a genuine natural key.** Django's `natural_key()` / `get_by_natural_key()` mechanism is the right tool when a row already has a real natural key for product reasons — Django's example is `(first_name, last_name)` backed by a `UniqueConstraint`, which lets `dumpdata --natural-primary` omit primary keys from fixtures entirely (https://docs.djangoproject.com/en/5.2/topics/serialization/). **Use the natural key when the entity has one anyway; use label-derived identifiers when it does not, rather than inventing one.**
|
||
|
||
### 8.6 The implication for catalog entities
|
||
|
||
**On Route A, catalog entities do not need a key column in order to be seeded, tested, or found deterministically.** Determinism is available at the identity layer, where it costs nothing and is invisible to the product. A key on a catalog entity then survives only on C4 — declarative configuration outside the database names it and must survive a rename. That is a real and common condition, and it is the one to check; it is not the same thing as "our seeds use it," and treating the two as equivalent is how catalogs accumulate keys nobody outside the test suite ever reads.
|
||
|
||
**On Route B the conclusion inverts, and this is the single most consequential branch in the pattern.** With identifiers generated by the database, no caller can address a catalog row without a key, so catalog entities carry one as a matter of class rather than as a finding about consumers. Deciding it by class is the better discipline in any case: it answers the question for a table that does not exist yet, whereas adjudicating each column against a consumer test invites the adjudicator to invent a consumer for a column they are already attached to.
|
||
|
||
### 8.7 Three adjacent mechanisms, and what they are actually for
|
||
|
||
These are frequently proposed as solutions to the seeding problem. None of them is one.
|
||
|
||
**Stripe's `lookup_key` is a transferable alias, not a seeding device.** It is "A lookup key used to retrieve prices dynamically from a static string," and `transfer_lookup_key=true` "will atomically remove the lookup key from the existing price, and assign it to this price" (https://docs.stripe.com/api/prices/object). The stated purpose is deployment-free price migration: "if you decide that you want to start charging new users 20 USD per month rather than 10 USD per month, you only need to create a new price and transfer the lookup key to that new price" (https://docs.stripe.com/products-prices/manage-prices). This is C4 — a configuration-level name that outlives the object behind it. If your application hardcodes catalog identifiers in deployed code, this pattern is the fix, and it is worth adopting for its own sake.
|
||
|
||
**Stripe's idempotency key is a request identifier, not an entity identifier.** Stripe's idempotency "works by saving the resulting status code and body of the first request made for any given idempotency key," suggests "V4 UUIDs, or another random string with enough entropy," and prunes keys "after they're at least 24 hours old" (https://docs.stripe.com/api/idempotent_requests). It makes a retry safe; it does not make a row findable later. `ON CONFLICT` on the primary key is the durable equivalent for seeding.
|
||
|
||
**Temporal's Workflow ID is a genuine business handle, and its warning is the one to import.** A Workflow ID is "a customizable, application-level identifier … meant to be a business-process identifier, such as customer identifier or order identifier," with the platform guaranteeing at most one open execution per ID, alongside a separate system-generated Run ID for each execution — the same two-identifier split this pattern recommends. Temporal's caution should be copied verbatim into any handle you design: "Do not include sensitive data, secrets, or personally identifiable information (PII) as a Workflow Id," because such identifiers "are stored in plain text … and are visible in the Temporal Web UI, CLI output, Event History, and system logs" (https://docs.temporal.io/workflow-execution/workflowid-runid). **Handles populated from authentication-provider fields — a login name, an email local part — violate this, and the violation is invisible precisely because nothing displays the value.**
|
||
|
||
**Terraform shows the same separation from the client's side.** A resource address is `resource_type.resource_name[index]`, where `resource_name` is "User-defined name of the resource" (https://developer.hashicorp.com/terraform/cli/state/resource-addressing), and state exists "to store bindings between objects in a remote system and resource instances declared in your configuration" (https://developer.hashicorp.com/terraform/language/state). The declarative client keeps its own stable logical name and maintains the mapping to the provider's opaque physical ID. It does not require the provider to adopt human-readable identifiers. **A seed deriving identifiers from labels is doing the same thing with the mapping computed rather than stored** — which is strictly cheaper, since there is no state file to lose.
|
||
|
||
**A note on why declarative clients want user-specified IDs at all**, since it is the strongest argument on the other side. AIP-133 states it: "Declarative clients use the resource ID as a way to identify a resource for applying updates and for conflict resolution. The lack of a user-specified ID means a client is unable to find the resource unless they store the identifier locally, and can result in re-creating the resource… Having a user-specified ID also means the client can precalculate the resource name and use it in references from other resources" (https://google.aip.dev/133). Note the operative verb: **precalculate**. Label-derived identifiers give a client precisely that ability without the server exposing a second identifier namespace.
|
||
|
||
---
|
||
|
||
## 9. Vocabulary
|
||
|
||
Words carry different contracts across the sources, and using them loosely is how the roles get conflated. Recommended usage, with the evidence:
|
||
|
||
| Term | Use it for | Evidence |
|
||
|---|---|---|
|
||
| **ID** | The opaque identity. | Stripe `id` "Unique identifier for the object"; Heroku `id` as `uuid`; AIP-148 `uid`, a system-assigned UUID |
|
||
| **Name** | The mutable display label. | AIP-148 `display_name`, mutable and non-unique; GCP project name; Stripe product `name` |
|
||
| **Key** | A stable machine string a vocabulary owns. | Stripe `lookup_key`; Jira "space key"; Zalando §241 "compound key" |
|
||
| **Handle** | A user-chosen, unique, human-readable address a person types or says. | GitHub REST docs: `username` is "The handle for the GitHub user account"; atproto: "Handles are mutable and human-friendly account usernames" while the DID persists |
|
||
| **Reference number** | A sequential citation token for a document. | Jira work item key = space key + sequential number; Stripe invoice numbers |
|
||
| **External reference** | Another system's identifier for the same thing. | Discourse's `t/external_id/:external_id` route |
|
||
| **Path** | The URL-addressing form, when it differs from the name. | GitLab: "A project's path isn't necessarily the same as its name" |
|
||
|
||
**Do not call something a slug unless it appears in a URL.** In every product that uses the word, a slug is the URL-safe form of a title, living in a permalink: WordPress generates the post slug from the title and displays it beneath the title field; Discourse's route is `/t/:slug/:topic_id` where only `topic_id` is constrained and a moderator confirms the slug segment is decorative — "you have to have something in the `blah` spot in the URL. You can put whatever you want in there" (https://meta.discourse.org/t/slug-is-required-in-topic-url/45207). A column named `slug` that never appears in a URL is misnamed, and the misnaming is what lets such columns survive unexamined. Call it a key or a handle, whichever it is, and the question of whether it should exist becomes answerable.
|
||
|
||
---
|
||
|
||
## 10. Trade-offs, and when this pattern is the wrong one
|
||
|
||
Honest limits, so an adopter can tell whether they are in scope.
|
||
|
||
**This pattern costs you human-readable URLs by default.** If your product's value depends on shareable, readable, search-indexed addresses — a publishing platform, a documentation host, a public directory — you need handles on your primary content entities from day one, and Google Search Central's readability preference is directly relevant to you. The pattern still applies; you will simply find that C2 holds for more entities than it does in an internal tool.
|
||
|
||
**This pattern costs you conversational shorthand.** With opaque identities and non-unique names, "which account?" is answered by a name plus a disambiguator rather than a short token. Invest in search. If your support workflow genuinely needs a spoken token, that is C2 evidence and you should design a handle, not work around its absence.
|
||
|
||
**Opaque identifiers are worse for debugging.** Zalando's complaint is legitimate: reading logs full of 36-character identifiers is harder than reading logs full of names. Mitigate by logging the name alongside the identifier, not by making the name the identifier.
|
||
|
||
**High-volume, low-cardinality configuration data is the documented counter-case.** Zalando concludes that "Usage of UUIDs is especially discouraged as primary keys of master and configuration data, like brand-ids or attribute-ids which have low id volume but widespread steering function." That is precisely Class C, and the pattern already concedes it: vocabularies use the key as the primary key.
|
||
|
||
**Sequential integers remain simpler if you never distribute writes and never expose identifiers.** The opaque-identity argument earns its cost when identifiers appear in URLs, cross service boundaries, or are generated concurrently. If none of that is true, a `bigserial` and this pattern's role separation will serve you well; the roles matter more than the datatype.
|
||
|
||
**Not verified:** whether Keycloak permits supplying entity `id` values in a realm import file. The Keycloak import/export documentation consulted (https://www.keycloak.org/server/importExport) describes the CLI and Admin Console flows, consistency limitations, and what an export excludes, but does not state the identifier-supply behaviour. Confirm against your own version before relying on it.
|
||
|
||
---
|
||
|
||
## 11. Adoption checklist
|
||
|
||
For an existing model:
|
||
|
||
1. **Classify every table** into one of the six classes in §4. Disagreements here surface real modelling confusion and are worth resolving before proceeding.
|
||
2. **For every identifier column that is not the primary key and not a display name, write down which of C1–C4 justifies it.** Any column for which you cannot name a consumer is a candidate for removal. Any column justified only by "the seeds use it" is a candidate for §8.
|
||
3. **Check every unique constraint on a name.** Global uniqueness on a display name is the strongest signal that a name is doing an identifier's job. Convert to a parent-scoped, case-insensitive, live-rows-only guard, or remove it.
|
||
4. **Check every Class D and Class F table for live-resolved labels.** Every one is a history row that will misreport the past after the first rename.
|
||
5. **Check every Class F table for a reference number.** If people cite these documents to each other, the number is not optional.
|
||
6. **Check whether any entity needs to record an external system's identifier for itself.** Integration-heavy models often discover they have nowhere to put it.
|
||
7. **Write the vocabulary down** — in a glossary, not in tribal memory — and rename any column whose name misdescribes its role.
|
||
|
||
For a new entity, in order:
|
||
|
||
1. Which class is it?
|
||
2. It gets an opaque identity. Always.
|
||
3. Does a person need to recognise it in a list? Then it gets a name — mutable, non-unique.
|
||
4. Would two identically named siblings confuse someone? Then add a UX guard: per parent, case-insensitive, live rows only.
|
||
5. Does C1, C2, C3, or C4 hold? Only then does it get a key or a handle, and record which criterion. If your identifiers are database-generated (§8.2 Route B), classes A and B carry a nullable key by class and this step applies only to handles.
|
||
6. Is it a document people cite? Then it gets a reference number.
|
||
7. Is it a history row? Then it snapshots the labels it displays.
|
||
|
||
---
|
||
|
||
## 12. Sources
|
||
|
||
All pages were retrieved and read. Claims not established from these pages are marked *not verified* in the text.
|
||
|
||
**Standards and API guidelines**
|
||
- RFC 9562, *Universally Unique IDentifiers* — https://www.rfc-editor.org/rfc/rfc9562.txt (§5.5 UUIDv5, §5.7 UUIDv7, §6.5 name-based generation, §8 security considerations)
|
||
- Google AIP-122, *Resource names* — https://google.aip.dev/122
|
||
- Google AIP-133, *Standard methods: Create* — https://google.aip.dev/133
|
||
- Google AIP-148, *Standard fields* — https://google.aip.dev/148
|
||
- Google AIP-2510, *Project identifiers* — https://google.aip.dev/cloud/2510
|
||
- Microsoft REST API Guidelines §7.3, *Canonical identifier* — https://raw.githubusercontent.com/microsoft/api-guidelines/vNext/graph/Guidelines-deprecated.md
|
||
- Zalando RESTful API Guidelines, rules 144, 228, 241 — https://opensource.zalando.com/restful-api-guidelines/
|
||
- W3C, *Cool URIs don't change* — https://www.w3.org/Provider/Style/URI
|
||
- Google Search Central, *URL structure best practices* — https://developers.google.com/search/docs/crawling-indexing/url-structure
|
||
|
||
**Databases and frameworks**
|
||
- PostgreSQL 18, `INSERT` / `ON CONFLICT` — https://www.postgresql.org/docs/current/sql-insert.html
|
||
- Rails, `ActiveRecord::FixtureSet` — https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||
- Django, *Serializing Django objects* (natural keys) — https://docs.djangoproject.com/en/5.2/topics/serialization/
|
||
- Terraform, *State* — https://developer.hashicorp.com/terraform/language/state
|
||
- Terraform, *Resource address reference* — https://developer.hashicorp.com/terraform/cli/state/resource-addressing
|
||
- Kubernetes, *Object Names and IDs* — https://kubernetes.io/docs/concepts/overview/working-with-objects/names/
|
||
- Keycloak, *Importing and exporting realms* — https://www.keycloak.org/server/importExport
|
||
|
||
**Products**
|
||
- Stripe Prices object — https://docs.stripe.com/api/prices/object
|
||
- Stripe Products object — https://docs.stripe.com/api/products/object
|
||
- Stripe, *Manage prices* (lookup keys) — https://docs.stripe.com/products-prices/manage-prices
|
||
- Stripe, *Idempotent requests* — https://docs.stripe.com/api/idempotent_requests
|
||
- Temporal, *Workflow Id and Run Id* — https://docs.temporal.io/workflow-execution/workflowid-runid
|
||
- Grafana, *Dashboard HTTP API* — https://grafana.com/docs/grafana/latest/developer-resources/api-reference/http-api/dashboard.md
|
||
- Google Cloud, *Creating and managing projects* — https://cloud.google.com/resource-manager/docs/creating-managing-projects
|
||
- GitHub, *Changing your username* — https://docs.github.com/en/account-and-profile/setting-up-and-managing-your-personal-account-on-github/managing-user-account-settings/changing-your-github-username
|
||
- GitHub, *Renaming a repository* — https://docs.github.com/en/repositories/creating-and-managing-repositories/renaming-a-repository
|
||
- GitLab, *Working with projects* — https://docs.gitlab.com/user/project/working_with_projects/
|
||
- GitLab REST API — https://docs.gitlab.com/api/rest/
|
||
- Slack, *Change your workspace name and URL* — https://slack.com/help/articles/201663443-Change-your-workspace-or-org-name-and-URL
|
||
- Zulip, *Change organization URL* — https://zulip.com/help/change-organization-url
|
||
- Jira, *Edit a project's details* — https://support.atlassian.com/jira-work-management/docs/edit-a-projects-details/
|
||
- Notion, *Manage your Notion Sites* — https://www.notion.com/help/manage-your-notion-sites
|
||
- Shopify, *Domains* — https://help.shopify.com/en/manual/domains
|
||
- Nextcloud, *User configuration* — https://docs.nextcloud.com/server/latest/admin_manual/configuration_user/user_configuration.html
|
||
- Heroku Platform API reference — https://devcenter.heroku.com/articles/platform-api-reference
|
||
- WordPress, *Write posts* — https://wordpress.org/documentation/article/write-posts-classic-editor/
|
||
- Discourse meta, *Slug names should never change on title changes* — https://meta.discourse.org/t/seo-issue-slug-names-for-topics-should-never-change-on-title-changes/162580
|
||
- Discourse meta, *Slug is required in topic URL* — https://meta.discourse.org/t/slug-is-required-in-topic-url/45207
|
||
- Mastodon, *Sign up* — https://docs.joinmastodon.org/user/signup/
|
||
- AT Protocol, *Handle* — https://atproto.com/specs/handle
|
||
|
||
*Product-survey citations in §5.2 and §5.4 draw on a 23-product primary-source survey conducted 2026-08-28; the pages cited above are those quoted directly here.*
|