Identity & the Registry
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
| Entity | Defined in | Key fields | Role |
|---|---|---|---|
RegistryEntry | registry/models/entry.py | entry_id, namespace, entity_type, primary_composite_key, primary_composite_key_hash, synonyms[], status, search_values[], metadata | The identity record, one per logical entity. The sole read model for resolution, search, and export. |
Synonym (embedded) | registry/models/entry.py | namespace, entity_type, composite_key, composite_key_hash, source_info | An 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. |
CompositeKeyClaim | registry/models/composite_key_claim.py | namespace, 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. |
Namespace | registry/models/namespace.py | prefix, isolation_mode (open|strict), allowed_external_refs, id_config, deletion_mode (retain|full), status | The tenancy boundary. Also carries the per-entity-type ID-generation config (get_id_algorithm). |
NamespaceGrant | registry/models/grant.py | namespace, subject, subject_type (user|api_key|group), permission (read|write|admin), expires_at | Who may read/write/admin a namespace. Gates enumeration, not identity resolution — see Gotchas. |
IdAlgorithmConfig / IdCounter | registry/models/id_algorithm.py, registry/models/id_counter.py | algorithm (uuid7 default | uuid4 | prefixed | nanoid | pattern | any), atomic seq | Per-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):
| Status | Meaning | Reached via |
|---|---|---|
reserved | ID allocated, not yet resolvable | POST /entries/provision, POST /entries/reserve |
active | Resolvable | POST /entries/register (immediately), or POST /entries/activate on a reserved entry |
inactive | Soft-deleted, or the losing side of a merge | DELETE /entries (soft path), POST /synonyms/merge on the deprecated entry |
| (removed) | Hard-deleted — the document is physically gone from MongoDB | DELETE /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:
POST /entries/register(registry/api/entries.py::register_keys) is "reserve + activate" in one call. It computesHashService.compute_composite_key_hashover the submittedcomposite_key; if the caller also sendsidentity_values(a template's raw identity-field values), the Registry computes a second hash from those, injects it intocomposite_key["identity_hash"]before hashing the full key, and — unlessskip_identity_value_synonymis set (document-store sets this for relationship/edge types, whose bare{source_ref, target_ref}key would collide across edge types) — records the rawidentity_valuesas aSynonym. All items in one/registerbatch must share a namespace, or the call is rejected (422).POST /entries/provision(registry/api/entries.py::provision_ids) has the Registry generate IDs per the namespace'sIdAlgorithmConfigand insert themreserved.POST /entries/reserve(registry/api/entries.py::reserve_ids) takes client-supplied IDs, validates each against the namespace's configured format (IdFormatValidator.validate), and inserts themreserved.
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
- Namespace-scoped dedup. The same composite key in two different namespaces is two separate registry entries, enforced by the compound unique index
(namespace, entity_type, primary_composite_key_hash)onRegistryEntry(registry/models/entry.py). - Identity hash is exact-value, case-sensitive, over the raw submitted key.
HashService.compute_composite_key_hashrecursively sorts the dict's keys, JSON-serializes with sorted keys and no whitespace, and SHA-256s the result (registry/services/hash.py) — there is no normalization step. A prior case-insensitivenormalize_value/compute_field_hashpair was deliberately removed (CASE-568) because it disagreed with this hash and had no callers. - One owner per key, enforced twice. A composite-key hash resolves to at most one entry per
(namespace, entity_type), guarded by two independent unique indexes:RegistryEntry's own primary-key index, andCompositeKeyClaim's unified index across both primary and synonym hashes — the latter exists specifically because a MongoDB unique index cannot police an embedded array (RegistryEntry.synonyms) the way it polices a top-level field (registry/models/composite_key_claim.py, module docstring).CompositeKeyClaimis write-time only;RegistryEntrystays the sole read model. - Entry lifecycle is one-directional except merge.
reserved → active → inactive(soft-delete); hard-delete permanently removes the document and requires the owning namespace'sdeletion_mode == "full"(registry/api/entries.py::delete_entries). Arollback_uncommittedflag exists for privileged callers (wip-admins/wip-services) to hard-delete a just-reserved, never-committed entry — a trusted write-rollback, not user-data deletion — bypassing thedeletion_modegate. - Enumeration is grant-gated; resolution is not. Listing/searching a namespace's entries (
GET /entries,GET /entries/search) is scoped to the caller's accessible namespaces viaresolve_accessible_namespaces(registry/api/grants.py); resolving a specific ID or composite key (lookup/by-id,lookup/by-key,resolve) performs no such check. See Gotchas. - A locked namespace is unreachable to everyone.
_resolve_permission(registry/api/grants.py) checks namespace lock status before the superadmin bypass —if ns and ns.status == "locked": return "none"runs first, so a namespace mid-deletion resolves to "none" even forwip-admins. - The
wipnamespace is bootstrapped, not configured. If absent at startup, the Registry creates thewipnamespace and its default group grants (wip-admins→admin,wip-editors→write,wip-viewers→read) automatically (registry/main.py::lifespan).
⚠ 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://ponifsis outside this doc'ssource_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
- Resolution deliberately bypasses namespace grants.
lookup_by_ids,lookup_by_keys, andresolve_synonymsnever callresolve_accessible_namespaces— any caller holding a valid API key can resolve any ID or composite key in any namespace, while listing or searching that same namespace's entries requires a grant on it. The code says this is intentional ("Reference resolution ... stays cross-namespace by design; unified search does not,"registry/api/entries.py), but it is easy to assume identity resolution is scoped the same way everything else is. - A
DuplicateKeyErroron a claim is not necessarily a conflict.CompositeKeyClaim.claimtreats a duplicate from the sameowner_entry_idas a no-op self-retry and returns the existing claim; callers only see a raised error when a different entry already owns the hash (registry/models/composite_key_claim.py::claim). - A missing claim never fails an already-committed entry. If
claim_entry_keyshits aDuplicateKeyErroror any other exception after an entry insert, it logs and moves on — the entry is already committed and its primary-key uniqueness was already enforced byRegistryEntry's own index; the gap is left for the idempotent startup reconciliation (registry/services/claims.py::claim_entry_keys). - Merge is one-directional.
POST /synonyms/mergeembeds the deprecated entry'sentry_idas a synonym of the preferred entry and marks the deprecated entryinactive; there is no unmerge endpoint in this scope. identity_valuesandcomposite_keyare not the same hash. Sendingidentity_valuescomputes a second, separate hash that gets injected intocomposite_keybefore the full key is hashed — the entry'sprimary_composite_key_hashis a hash of the augmented key, not ofidentity_valuesalone. Theidentity_hashreturned to the caller is the value computed fromidentity_valuesin isolation (registry/api/entries.py::register_keys, phase 1).skip_identity_value_synonymstill computes the hash. Setting it only suppresses the raw-valuesSynonymrecord;identity_hashis still computed and still injected intocomposite_keyfor dedup, so identity-based versioning is unaffected even though no synonym is created (registry/models/api_models.py::RegisterKeyItem,registry/api/entries.py).
Code findings
reserve_ids'salready_existsoutcome is silently counted as an error, unlike its siblingregister_keys.POST /entries/registergivesalready_existsits own bucket (RegisterBulkResponse.already_exists,registry/models/api_models.py:170), separate fromerrors.POST /entries/reservehas no such field onReserveBulkResponse(registry/models/api_models.py:230-236); when the ID is already taken,reserve_idssets the per-itemstatus="already_exists"but incrementserror_count(registry/api/entries.py:688-695), so an outcome the API itself labels as not-an-error is folded into the bulk response'serrorstotal. The two sibling bulk endpoints disagree on how the same situation is classified.IdGenerator.generate_uuid7derives its timestamp withdatetime.utcnow().timestamp(), which is not UTC-safe.registry/models/id_algorithm.py:102:timestamp_ms = int(datetime.utcnow().timestamp() * 1000).datetime.utcnow()returns a naive datetime; Python'sdatetime.timestamp()on a naive instance interprets it as local time, not UTC (per the stdlib's own documented behavior for naive datetimes). So on any host whose local timezone isn't UTC, the millisecond component embedded in the generated UUID7 is offset by the host's UTC offset from the wall-clock instant — undermining the "time-ordered" property the docstring claims, and making the embedded timestamp inconsistent across hosts running in different timezones. Every other timestamp in this scope uses the timezone-awaredatetime.now(UTC)(e.g.registry/models/entry.py,registry/models/grant.py,registry/models/composite_key_claim.py); this is the one exception.
See also
Data Model Reference, Namespaces & Tenancy.
