A survey of the twenty notebooks on disk found general facts that no tracked page stated; the maintainer struck the weak rows and the rest are written into their owning pages in each page's own words. The design-system page gains the hidden-navigation figures that replaced a dangling survey pointer, the reason the anatomy is parts and lint, the record-table grouping and single-tint rules, and when a surface earns row editing. The first-contact process gains a code-verification phase, the transcript lane for models without vision, the three kinds of honesty-audit drift and the click-path guardrail. The operator conventions gain guards-before-writes, error state over empty state, copy that states the real effect, toasts that report counts and the PostForm rule. The Stripe, FedWiki, identifiers, IA, testing, model and environment pages each gain their facts, the integration guide stops telling authors to hand-write a page title, issues.md logs the placeholder security contact, milestones.md records the Codeberg terms clause and CONTRIBUTING.md notes that the module path is the forge URL. One claimed fact was checked against the code and not written: the mass-mutation preview does not run the commit loop in a rolled-back transaction.
168 lines
8.6 KiB
Markdown
168 lines
8.6 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 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).
|
|
|
|
Three operator surfaces still resolve their labels live: the organization
|
|
composite's Tier changes table, its current-rungs line, and the landing
|
|
page's recent-activity timeline all read tier, product, and actor names
|
|
from the catalog's current shape at render time, so renaming a product or
|
|
reordering a ladder changes what a past row reads. The Tier changes
|
|
section header discloses that drift ("Tier names reflect the ladder's
|
|
current shape.") rather than fixing it.
|