Files
member-console/docs/identifiers.md
T
cgalo5758 568383b55e Reorganize status docs and add identifier docs
Move settled investigation records into
`status/explorations/<topic>-<date>/` with README indexes,
promote durable conventions to `docs/`, and add
`docs/identifiers.md` plus upstream Doc 46 person-name
provenance documents. Update cross-references and archive
resolved issues.
2026-08-29 21:15:31 -05:00

162 lines
8.3 KiB
Markdown

---
title: "Identifiers"
audience: [developer]
summary: "How member-console names and identifies entities: the database-generated ID, the display name, the nullable key that seeds and configuration address rows by, and the rules for each."
---
# Identifiers
This page states the console's identifier conventions. The normative rules
they apply live upstream and are mirrored in `design/`: the Entity Key
Contract in [`design/data-model.md`](../design/data-model.md) (Structural
Policies), [Doc 45, Identifier Policy](../design/documents/doc-45-identifier-policy.md),
and the portable article
[Naming and Identifying Entities](../design/documents/pattern-entity-identifiers.md).
The investigation that produced them is frozen in
[`status/explorations/identifiers-2026-08/`](../status/explorations/identifiers-2026-08/README.md);
the change that implemented them is archived as
`openspec/changes/archive/2026-08-29-entity-keys/`. The schema is migration
`internal/db/migrations/00012_entity_keys.sql`.
## The three roles
Every entity has an **ID** and a **name**. Some entities also have a **key**.
- **ID.** A UUIDv7, always generated by the database (Decision 30). It is the
only identity: URLs, foreign keys, history, and the API use it. It is never
supplied, derived, or pinned by application code, seeds, or tests, and it
is never a security capability.
- **Name.** Presentation. Mutable, and non-unique unless a name guard scopes
it (below).
- **Key.** A unique string that lets something outside the database address
the row without knowing its generated ID: code, configuration, seeds,
tests, scripts, API clients, other systems. A seed is code naming a row
literally, so seeds are ordinary consumers of keys.
The vocabulary is `id`, `name`, `key`, `handle` (a public address, which no
console entity has today), and an external system's own term for its
identifiers. "Slug" is not vocabulary: a slug is the URL form of a title, and
no console column ever carried one.
## Which entities carry a key
Decided by class, so a new entity is placed by its class and the question
does not recur:
| Class | Key |
|---|---|
| Tenants and actors (organizations, workspaces, resource pools) | `key`, nullable, unique in scope |
| Catalog (plan ladders, entitlement sets, products, prices) | `key`, nullable, unique in scope |
| Vocabularies (org types, providers, resource keys, roles) | the primary key is the key (`org_type`, `provider`, `resource_key`, `role_name`) |
| Ledgers (grants, provisions, transitions, payments) | none; addressed by ID, labels snapshotted at write time |
| Relationships (memberships, assignments, tiers) | none; the identity is the pair |
| Documents (invoices) | none; the reference number is the citation |
One exception, itself a rule: where an external system's identifier already
uniquely names every row, that column is the key and no second one is added.
Persons and users are addressed by `oidc_subject`; FedWiki sites by hostname;
domain claims by FQDN; Discourse links by forum user ID; provider mappings by
Stripe IDs.
## Scope
A key is unique within the smallest namespace the entity inhabits: the
parent where one exists, the table where none does. This is the same rule
the name guards use. Root entities: `UNIQUE (key)`. Children:
`UNIQUE (parent_id, key)`, so workspaces and pools are `(org_id, key)` and
prices are `(product_id, key)`. A child's declarative address is its
parent's key plus its own, never the parent plus a name, because a name is a
display surface and a seed matching on one re-creates the row after a
rename. A child that is a singleton by construction has no key and is
addressed through its parent.
## The column contract
- `key TEXT NULL`, one `CHECK` per table: `^[a-z][a-z0-9_]*$`, at most 64
characters. This is the vocabulary grammar already used by org types,
resource keys, and roles; provider keys additionally contain no separator
because they prefix resource keys. One grammar means one validator and one
error message.
- Postgres treats NULLs as distinct, so rows without a key never conflict
and no partial index is needed.
- A key may change, and the old value is freed, not retired. A key is a
declarative address, not a public handle; nothing resolves an old key
lazily. Changing a key that a seed or a configuration file names breaks
that seed or file, as renaming a Kubernetes object does; that is the
author's responsibility. A key addresses whichever row holds it now.
- The system never derives a key from a person's data (a login name, a
display name, a timestamp). It writes a key only as a fixed constant for a
row it creates by design: the System tenant (`system`) and each
organization's default pool (`default`). Personal organizations and
default workspaces get no key.
- Where a row is also located by a structural predicate, the predicate is
the identity and the key is only an address: code resolves the System
tenant by `org_type = 'system'` and the default pool by
`pool_type = 'default'`, never by key. The constant keys are written once
at creation for seeds, configuration, and scripts.
## Name guards
Names are unique only where people pick the entity from a bare list, scoped
to the smallest namespace the entity inhabits, compared case-insensitively
over live rows: workspace names per organization (`uq_workspaces_org_id_name_ci`,
excluding deleted workspaces), plan ladder names across the catalog
(`uq_plan_ladders_name_ci`), entitlement set names across the catalog.
Product names are not unique, because a tier named "Pro" on two ladders is
legitimate; the product forms warn about duplicates instead.
## Surfaces
- Create forms never collect a key.
- Detail pages show the key read-only when one is set, labelled "Key", in
the muted code style the org-type card uses, and nothing when it is NULL.
Operator editing of a key from the detail page is a deferred slice.
- Operator lookup resolves an exact key (organizations, plan ladders,
entitlement sets, products) before the name search.
- Every keyed entity has a `Get<Entity>ByKey` (root) or
`Get<Entity>ByParentAndKey` (child) query.
- The API (M12) addresses by ID, offers `?key=` lookup, and accepts an
optional `key` on create. Client-supplied IDs are not offered; they
conflict with Decision 30.
## Seeds and tests
- A seed's job is to ensure a row exists. It reads by key, creates with the
key when absent, and never updates an existing row's fields
(`ON CONFLICT (key) DO NOTHING`, or get-then-create). Because a key may be
reassigned, a seed naming a key it no longer owns lands on whichever row
now holds it; ensuring existence makes that harmless where updating would
silently overwrite an operator's edits. A declarative loader whose file is
the truth may update, and must say so; none exists.
- The demo seed keys its rows `demo_ladder`, `demo_baseline`, `demo_plan`,
`demo_plan_2`, `demo_plan_3`, `demo_addon`, `demo_usage`, `demo_onetime`.
The mockshot sample data keys its organizations `mockshot_0001` onward and
marks its persons with `mockshot:`-prefixed OIDC subjects; the nuke deletes
by those two patterns and by the catalog keys.
- Keycloak keeps its own model: users matched by `username`, with the four
demo users' ids pinned as literals in `test/seed/keycloak/seed-keycloak.sh`
and mirrored by the demo seed's person subjects.
- Test fixtures find rows by key where a seed set one, and by the unique
name the test itself submitted otherwise.
## Handles and reference numbers
A handle is a public address people say and type outside the console. The
console has none today; FedWiki hostnames and claimed domains are addresses
owned by DNS and the farm. If one is ever added, it is derived from the name
by default, editable at creation, mutable afterwards with the old value
retired rather than recycled, and always beside the ID.
An invoice number is a reference number, not a handle: a citation assigned
at issuance from a per-billing-account counter inside the issuance
transaction (gapless within the account), formatted `%04d`. Stripe's own
number is stored as an external reference in `stripe.invoice_mappings`.
## History
History rows cite the ID and snapshot the labels in force at write time;
they never resolve names live, because a rename or a tier reorder would
rewrite the past. The audit reason strings for ladder changes already do
this; snapshot columns on the transition tables are a pending upstream
decision (Doc 33).