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

8.3 KiB

title, audience, summary
title audience summary
Identifiers
developer
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, 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).