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.
8.3 KiB
title, audience, summary
| title | audience | summary | |
|---|---|---|---|
| Identifiers |
|
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 (Structural
Policies), Doc 45, Identifier Policy,
and the portable article
Naming and Identifying Entities.
The investigation that produced them is frozen in
status/explorations/identifiers-2026-08/;
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, oneCHECKper 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 bypool_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) orGet<Entity>ByParentAndKey(child) query. - The API (M12) addresses by ID, offers
?key=lookup, and accepts an optionalkeyon 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 organizationsmockshot_0001onward and marks its persons withmockshot:-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 intest/seed/keycloak/seed-keycloak.shand 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).