← Back to Library

Identity & the Registry

concept ·  modelers, app devs

Identity & the Registry

In one sentence. The Registry is WIP's federated identity service: for every (namespace, entity_type) pair — entity types are terminologies, terms, templates, documents, files — it mints one canonical entry_id per logical entity and resolves any of that entity's composite-key representations, primary or synonym, back to the same entry_id (registry/main.py; registry/models/id_algorithm.py::VALID_ENTITY_TYPES).

Why it exists. The same entity gets addressed by more than one key shape across WIP: a composite key at creation time, a set of raw identity-field values forwarded from a template's identity_fields, a client-supplied ID, a merged-away former ID. Without one arbiter, two callers registering "the same" entity under two different key shapes would mint two live IDs. The Registry is that arbiter — a RegistryEntry per entity, a set of Synonyms that all resolve to it, and (since CASE-427) a dedicated CompositeKeyClaim collection that turns "is this key already taken" into an atomic, database-enforced check instead of a check-then-insert race (registry/models/composite_key_claim.py, module docstring).

The model

EntityDefined inKey fieldsRole
RegistryEntryregistry/models/entry.pyentry_id, namespace, entity_type, primary_composite_key, primary_composite_key_hash, synonyms[], status, search_values[], metadataThe identity record, one per logical entity. The sole read model for resolution, search, and export.
Synonym (embedded)registry/models/entry.pynamespace, entity_type, composite_key, composite_key_hash, source_infoAn alternative composite key that resolves to the parent entry. Lives inside RegistryEntry.synonyms; a synonym's own namespace/entity_type can differ from its parent entry's.
CompositeKeyClaimregistry/models/composite_key_claim.pynamespace, entity_type, composite_key_hash, owner_entry_id, kind (primary|synonym)The write-time uniqueness gate spanning primary and synonym hashes in one domain. Not a read model — it exists only so the unique index can lock what an embedded array can't.
Namespaceregistry/models/namespace.pyprefix, isolation_mode (open|strict), allowed_external_refs, id_config, deletion_mode (retain|full), statusThe tenancy boundary. Also carries the per-entity-type ID-generation config (get_id_algorithm).
NamespaceGrantregistry/models/grant.pynamespace, subject, subject_type (user|api_key|group), permission (read|write|admin), expires_atWho may read/write/admin a namespace. Gates enumeration, not identity resolution — see Gotchas.
IdAlgorithmConfig / IdCounterregistry/models/id_algorithm.py, registry/models/id_counter.pyalgorithm (uuid7 default | uuid4 | prefixed | nanoid | pattern | any), atomic seqPer-namespace, per-entity-type ID-minting strategy. prefixed draws from an atomic MongoDB counter keyed {namespace}:{entity_type}:{prefix}.

Entry lifecycle (registry/models/entry.py docstring; registry/api/entries.py):

StatusMeaningReached via
reservedID allocated, not yet resolvablePOST /entries/provision, POST /entries/reserve
activeResolvablePOST /entries/register (immediately), or POST /entries/activate on a reserved entry
inactiveSoft-deleted, or the losing side of a mergeDELETE /entries (soft path), POST /synonyms/merge on the deprecated entry
(removed)Hard-deleted — the document is physically gone from MongoDBDELETE /entries with hard_delete=true, gated by the owning namespace's deletion_mode

How it works

Every write endpoint in this scope is bulk: it accepts a list and returns 200 OK with one result per item; a 200 does not mean every item succeeded (registry/api/entries.py, registry/api/synonyms.py — every router function takes items: list[...] = Body(...)).

Minting. Three paths put a RegistryEntry into existence:

ID generation itself (registry/services/id_generator.py::IdGeneratorService) reads the namespace's IdAlgorithmConfig for the entity type (falling back to DEFAULT_ID_CONFIG = uuid7 for every type) and dispatches to IdGenerator.generate_uuid7 / generate_uuid4 / generate_nanoid / generate_prefixed; the prefixed case pulls its sequence number from IdCounter.next_val's atomic findOneAndUpdate($inc).

Resolving. Three endpoints answer "what entry does this ID/key belong to": POST /entries/lookup/by-id, POST /entries/lookup/by-key, and POST /entries/resolve (registry/api/entries.py). Each tries the canonical entry_id first (when supplied), then falls back to a composite-key hash match against primary and synonym hashes. On the client side, every human-readable identifier used anywhere in WIP is verified through the Registry, never trusted directly: wip_auth/resolve.py::resolve_entity_id / resolve_entity_ids send UUID-shaped values as entry_id (direct verification) and everything else as composite_key (synonym resolution) to POST /api/registry/entries/resolve, with a 5-minute local TTL cache (_resolution_cache) so a repeated ID resolves from memory. Write paths that will persist the resolved ID pass bypass_cache=True to skip the cache read (a stale cache entry must not be baked into durable state) — the fresh result still refreshes the cache on return.

Deduplicating. claim_entry_keys (registry/services/claims.py) runs after every successful entry insert, claiming the primary hash plus every synonym hash into the CompositeKeyClaim domain — best-effort, since the entry's own primary-key uniqueness is already enforced atomically by RegistryEntry's own index. POST /synonyms/add (registry/api/synonyms.py::add_synonyms) claims the new synonym's hash before writing the embedded Synonym — the unique index is the lock, closing what used to be a check-then-insert race — and releases the claim if the subsequent entry save fails, so no orphan claim survives a failed write.

Merging. POST /synonyms/merge (registry/api/synonyms.py::merge_entries) folds a deprecated entry into a preferred one: the deprecated entry's own entry_id becomes a Synonym of the preferred entry (so old canonical-ID lookups keep resolving), every synonym the deprecated entry already owned is transferred (CompositeKeyClaim.transfer re-points ownership and marks it kind="synonym"), and the deprecated entry is set inactive.

Reconciling at boot. The Registry's startup lifespan (registry/main.py::lifespan) runs reconcile_orphan_claims (registry/services/claims.py) every boot — non-destructive; it only deletes claims whose backing entry/synonym no longer exists. The destructive counterpart, backfill_claims (which strips a losing duplicate synonym from its entry), is never run automatically; it is a deliberate one-shot admin entrypoint (registry/admin_backfill_claims.py), run once after the claims domain is introduced against an otherwise-populated RegistryEntry collection.

Rules & invariants

⚠ GAP: several rules above (grant-gated enumeration vs. cross-namespace-open resolution, especially) read as the kind of non-intuitive friction the reading list calls a PoNIF. wip://ponifs is outside this doc's source_scope, so this doc cannot confirm whether these are already canonically enumerated there, under what number, or with what exact wording — naming them here as behavior only, not as a numbered PoNIF citation.

Gotchas

Code findings

See also

Data Model Reference, Namespaces & Tenancy.

Generated from real source code, not hand-written — see the WIP Technical Library.