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.
162 lines
8.3 KiB
Markdown
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).
|