Registry API
# Registry API
**What it is.** The registry owns identity minting, namespaces, and synonyms for the WIP ecosystem. It registers composite keys as identities (`entry_id`), resolves synonym composite keys back to their canonical entry, provisions or validates IDs per a namespace's configured algorithm, and manages the namespaces themselves — their ID-generation config, access grants, export/import, and crash-safe deletion. It also manages runtime API keys.
**Reaching it.** Base path `/api/registry` (`components/registry/src/registry/api/__init__.py`). All endpoints require API-key authentication via the `X-API-Key` header, except the public probe/docs routes (not part of this API surface — see `schemas/registry.json`'s `info.description`). See also: Auth & Access; Routing & Ingress. Two auth levels appear directly in the route code: most endpoints depend on `require_api_key` (any valid key); namespace administration, deletion, and API-key management depend on `require_admin_key` (an admin-scoped key); `GET /api-keys/sync` depends on `require_groups(["wip-services", "wip-admins"])`.
**Conventions that apply.** Bulk-first 200 OK — every bulk endpoint (register, provision, reserve, activate, lookup, update, delete, resolve, synonym add/remove/merge, search, grant create/revoke) returns `200 OK` with a `results` array of per-item outcomes; a 200 does not mean every item succeeded — check each result's `status`. Namespace scoping — enumeration endpoints (`browse`, unified `search`, `search/by-fields`, `search/by-term`) restrict results to namespaces the caller can read; reference-resolution endpoints (`lookup/by-id`, `lookup/by-key`, `resolve`) intentionally stay cross-namespace. Identity & versioning — composite-key hashing is this store's core mechanism (see Store-specific gotchas); the registry is where `identity_hash` is computed. Pagination on `browse`/`search`/`list-api-keys` follows the platform-wide convention (`page`, `page_size`, `pages`, `total`) — see API Conventions. See also: API Conventions; PoNIFs.
## Endpoints
Endpoints are listed in router-registration order (`registry/api/__init__.py`'s `include_router` sequence, then each module's own route-declaration order), so regenerations diff cleanly.
### Namespace deletion (`components/registry/src/registry/api/namespace_deletion.py`, mounted at `/namespaces`)
### `DELETE /api/registry/namespaces/{prefix}`
- **Purpose.** Delete a namespace and all its data, or (with `dry_run`) report the impact without changing anything.
- **Request.** Path: `prefix`. Query: `dry_run` (bool, default `false`), `force` (bool, default `false` — proceed despite inbound references from other namespaces), `deleted_by` (str, optional). Requires an admin-scoped key.
- **Response.** `dry_run=true`: an impact report from the deletion service (entity counts, inbound references) — shape not fixed by a `response_model` in this route; the report-building logic lives in `registry/services/namespace_deletion.py`, outside this doc's scope.
> ⚠ GAP: the exact dry-run impact-report field shape isn't determinable from `registry/api/` + `registry/models/` alone — it's assembled by `NamespaceDeletionService.dry_run()` in `registry/services/`, which is out of this doc's `source_scope`.
Real delete, terminal states `completed` / `completed_with_warnings`: `{status, namespace, summary}` where `summary` is a `dict[str,int]` (from `DeletionJournal.summary`, `registry/models/deletion_journal.py`). Any other terminal/in-progress state: `{status, namespace, steps_completed, steps_total}`.
- **Errors.** `404` namespace not found. `400` target is the default `wip` namespace, or the namespace's `deletion_mode != 'full'` (must PATCH it to `'full'` first). `409` namespace is already `locked` (deletion in progress). `400` on a `ValueError` raised by the deletion service (e.g. blocked by inbound references without `force=true`).
- **Example.**
```bash
curl -X DELETE "https://<host>/api/registry/namespaces/dev-scratch?dry_run=true" \
-H "X-API-Key: $ADMIN_KEY"
```
### `GET /api/registry/namespaces/{prefix}/deletion-status`
- **Purpose.** Read the persisted journal for an in-progress or completed deletion.
- **Request.** Path: `prefix`. Requires a valid key (`require_api_key`).
- **Response.** `200`: `{namespace, status, requested_by, requested_at, completed_at, force, broken_references: [InboundReference], steps: [DeletionStep], summary}` — fields from `DeletionJournal`/`DeletionStep`/`InboundReference` (`registry/models/deletion_journal.py`). Each `DeletionStep` carries `{order, store, database, collection, action, detail, filter, storage_keys, status, deleted_count, error, completed_at}`; `status` is one of `pending`, `completed`, `completed_with_errors`, `failed` — `completed_with_errors` means the step (a best-effort MinIO/PostgreSQL cleanup) ran but a failure was swallowed, and a resumed journal re-runs it.
- **Errors.** `404` no deletion journal exists for the namespace.
- **Example.**
```bash
curl "https://<host>/api/registry/namespaces/dev-scratch/deletion-status" \
-H "X-API-Key: $API_KEY"
```
```json
{"namespace": "dev-scratch", "status": "completed", "steps": [...], "summary": {"mongodb_docs": 42}}
```
### `POST /api/registry/namespaces/{prefix}/resume-delete`
- **Purpose.** Resume an incomplete deletion (e.g. after a backend outage) from where it left off.
- **Request.** Path: `prefix`. Requires an admin-scoped key.
- **Response.** `200`: `{status, namespace, summary}`.
- **Errors.** `404` (surfaced as a caught `ValueError`) if there's nothing to resume.
- **Example.**
```bash
curl -X POST "https://<host>/api/registry/namespaces/dev-scratch/resume-delete" \
-H "X-API-Key: $ADMIN_KEY"
```
### `PATCH /api/registry/namespaces/{prefix}`
- **Purpose.** Update a namespace's `deletion_mode` (`retain` ↔ `full`).
- **Request.** Path: `prefix`. Query: `deletion_mode` (required, `retain` or `full`), `confirm_enable_deletion` (bool, default `false`), `updated_by` (str, optional). Requires an admin-scoped key.
- **Response.** `200`: `{prefix, deletion_mode, status}`.
- **Errors.** `400` invalid `deletion_mode` value; `404` namespace not found; `400` attempting to enable `full` on the default `wip` namespace; `400` flipping `retain → full` without `confirm_enable_deletion=true`.
- **Example.**
```bash
curl -X PATCH "https://<host>/api/registry/namespaces/dev-scratch?deletion_mode=full&confirm_enable_deletion=true" \
-H "X-API-Key: $ADMIN_KEY"
```
### Namespaces (`components/registry/src/registry/api/namespaces.py`, mounted at `/namespaces`)
### `GET /api/registry/namespaces`
- **Purpose.** List namespaces the caller can access.
- **Request.** Query: `include_archived` (bool, default `false`). Requires a valid key.
- **Response.** `200`: `list[NamespaceResponse]` — `{prefix, description, isolation_mode, allowed_external_refs, id_config, deletion_mode, status, created_at, created_by, updated_at, updated_by}`, filtered to namespaces where the caller's resolved permission `!= "none"`.
- **Errors.** None beyond auth (empty list on no access).
- **Example.**
```bash
curl "https://<host>/api/registry/namespaces" -H "X-API-Key: $API_KEY"
```
### `GET /api/registry/namespaces/{prefix}`
- **Purpose.** Get a namespace by prefix.
- **Request.** Path: `prefix`. Requires a valid key.
- **Response.** `200`: `NamespaceResponse` (see above).
- **Errors.** `404` if the namespace doesn't exist, or the caller's permission is `"none"` (indistinguishable from not-found, deliberately).
- **Example.**
```bash
curl "https://<host>/api/registry/namespaces/dev" -H "X-API-Key: $API_KEY"
```
### `GET /api/registry/namespaces/{prefix}/stats`
- **Purpose.** Get per-entity-type active-entry counts for a namespace.
- **Request.** Path: `prefix`. Requires a valid key.
- **Response.** `200`: `NamespaceStatsResponse` — `{prefix, description, isolation_mode, deletion_mode, status, entity_counts: dict[str,int]}`, one count per entity type in `VALID_ENTITY_TYPES` (`terminologies`, `terms`, `templates`, `documents`, `files`), counting only `status="active"` entries.
- **Errors.** `404` not found / no access.
- **Example.**
```bash
curl "https://<host>/api/registry/namespaces/dev/stats" -H "X-API-Key: $API_KEY"
```
### `GET /api/registry/namespaces/{prefix}/id-config`
- **Purpose.** Get the per-entity-type ID-algorithm config for a namespace. Services cache this at startup.
- **Request.** Path: `prefix`. Requires a valid key.
- **Response.** `200`: `dict[str, IdAlgorithmConfig]`, one entry per entity type — `{algorithm, prefix, pad, length, pattern}` (`registry/models/id_algorithm.py`; `algorithm` ∈ `uuid7`, `uuid4`, `prefixed`, `nanoid`, `pattern`, `any`).
- **Errors.** `404` namespace not found/no access (note: this route additionally filters to `status="active"` namespaces, so an archived namespace 404s here even though `GET /{prefix}` would still return it).
- **Example.**
```bash
curl "https://<host>/api/registry/namespaces/dev/id-config" -H "X-API-Key: $API_KEY"
```
### `POST /api/registry/namespaces`
- **Purpose.** Create a namespace.
- **Request.** Body `NamespaceCreate` (extra fields rejected): `prefix` (required), `description` (default `""`), `isolation_mode` (default `"open"`), `allowed_external_refs` (default `[]`), `id_config` (optional; unset entity types default to UUID7), `deletion_mode` (default `"retain"`), `created_by` (optional). Requires an admin-scoped key.
- **Response.** `200`: `NamespaceResponse`.
- **Errors.** `409` namespace with that prefix already exists.
- **Example.**
```bash
curl -X POST "https://<host>/api/registry/namespaces" -H "X-API-Key: $ADMIN_KEY" \
-H "Content-Type: application/json" -d '{"prefix": "dev"}'
```
### `PUT /api/registry/namespaces/{prefix}`
- **Purpose.** Upsert a namespace — create it if absent, or patch only the fields explicitly supplied if present. Makes bootstrap scripts idempotent (replaces `GET → 404 → POST`).
- **Request.** Path: `prefix`. Body `NamespaceUpdate`: `description`, `isolation_mode`, `allowed_external_refs`, `id_config`, `deletion_mode`, `confirm_enable_deletion` (safety toggle, not persisted), `updated_by` — all optional, `exclude_unset` semantics. Requires an admin-scoped key.
- **Response.** `200`: `NamespaceResponse`.
- **Errors.** `400` on the same `deletion_mode` safety guards as `PATCH .../{prefix}` (§ Namespace deletion): the `wip` namespace can never take `full`, and `retain → full` needs `confirm_enable_deletion=true`.
- **Example.**
```bash
curl -X PUT "https://<host>/api/registry/namespaces/dev" -H "X-API-Key: $ADMIN_KEY" \
-H "Content-Type: application/json" -d '{"description": "dev sandbox"}'
```
### `POST /api/registry/namespaces/{prefix}/archive`
- **Purpose.** Archive a namespace — no new entries can be created in it.
- **Request.** Path: `prefix`. Query: `archived_by` (optional). Requires an admin-scoped key.
- **Response.** `200`: `NamespaceResponse` with `status="archived"`.
- **Errors.** `400` target is the default `wip` namespace; `404` not found.
- **Example.**
```bash
curl -X POST "https://<host>/api/registry/namespaces/dev/archive" -H "X-API-Key: $ADMIN_KEY"
```
### `POST /api/registry/namespaces/{prefix}/restore`
- **Purpose.** Restore an archived namespace to `active`.
- **Request.** Path: `prefix`. Query: `restored_by` (optional). Requires an admin-scoped key.
- **Response.** `200`: `NamespaceResponse` with `status="active"`.
- **Errors.** `404` not found; `400` namespace isn't currently `archived`.
- **Example.**
```bash
curl -X POST "https://<host>/api/registry/namespaces/dev/restore" -H "X-API-Key: $ADMIN_KEY"
```
### `POST /api/registry/namespaces/initialize-wip`
- **Purpose.** Initialize the default `wip` namespace (UUID7 for every entity type). Idempotent — returns the existing namespace if already initialized.
- **Request.** No body. Requires an admin-scoped key.
- **Response.** `200`: `NamespaceResponse`.
- **Errors.** None beyond auth.
- **Example.**
```bash
curl -X POST "https://<host>/api/registry/namespaces/initialize-wip" -H "X-API-Key: $ADMIN_KEY"
```
### `POST /api/registry/namespaces/{prefix}/export`
- **Purpose.** Export a namespace to a downloadable archive.
- **Request.** Path: `prefix`. Query: `include_files` (bool, default `false`). Requires an admin-scoped key.
- **Response.** `200`: `ExportResponse` — `{export_id, prefix, download_url, stats: dict[str,int]}`. The archive itself is written to a temp file server-side and fetched separately via the download endpoint below.
> ⚠ GAP: the archive's internal format/manifest structure is built by `registry/services/export_service.py`, outside this doc's `source_scope`.
- **Errors.** `404` namespace not found; `500` on export failure.
- **Example.**
```bash
curl -X POST "https://<host>/api/registry/namespaces/dev/export" -H "X-API-Key: $ADMIN_KEY"
```
### `GET /api/registry/namespaces/exports/{export_id}`
- **Purpose.** Download a previously exported namespace archive.
- **Request.** Path: `export_id`. Requires a valid key.
- **Response.** `200`: the `.zip` file (`application/zip`), streamed from a temp-directory path (`os.path.join(tempfile.gettempdir(), f"{export_id}.zip")`).
- **Errors.** `404` file not found or expired (temp files aren't retained indefinitely).
- **Example.**
```bash
curl "https://<host>/api/registry/namespaces/exports/<export_id>" -H "X-API-Key: $API_KEY" -o export.zip
```
### `POST /api/registry/namespaces/import`
- **Purpose.** Import a namespace from an exported archive.
- **Request.** Multipart body: `file` (the `.zip`). Query: `target_prefix` (optional, remaps the prefix), `mode` (`create` | `merge` | `replace`, default `create`), `imported_by` (optional). Requires an admin-scoped key.
- **Response.** `200`: `ImportResponse` — `{prefix, mode, stats: dict[str,int], source_prefix}` (`source_prefix` set only when the import remapped the archive's original prefix).
> ⚠ GAP: the archive-parsing/merge logic itself is `registry/services/import_service.py`, outside this doc's `source_scope`.
- **Errors.** `400` invalid `mode`, or a `ValueError` from the import service; `500` other import failure.
- **Example.**
```bash
curl -X POST "https://<host>/api/registry/namespaces/import?mode=create" \
-H "X-API-Key: $ADMIN_KEY" -F "file=@dev-export.zip"
```
### Grants (`components/registry/src/registry/api/grants.py`, mounted at `/namespaces`)
### `GET /api/registry/namespaces/{prefix}/grants`
- **Purpose.** List grants on a namespace.
- **Request.** Path: `prefix`. Requires a valid key **and** admin permission on that specific namespace (checked in-handler via `_resolve_permission`, not via `require_admin_key` — a scoped-admin caller, not necessarily a globally admin-scoped key, can call this).
- **Response.** `200`: `list[GrantResponse]` — `{namespace, subject, subject_type, permission, granted_by, granted_at, expires_at}`.
- **Errors.** `403` caller's resolved permission on `prefix` is below `admin`.
- **Example.**
```bash
curl "https://<host>/api/registry/namespaces/dev/grants" -H "X-API-Key: $API_KEY"
```
### `POST /api/registry/namespaces/{prefix}/grants`
- **Purpose.** Create or update grants on a namespace (bulk, upsert by subject+subject_type).
- **Request.** Path: `prefix`. Body: `list[GrantCreate]` — `{subject, subject_type: "user"|"api_key"|"group" (default "user"), permission: "read"|"write"|"admin" (default "read"), expires_at}`. An `api_key`-typed `subject` given as `"apikey:<name>"` is normalized to the bare key name before storage. Requires namespace-admin permission (same check as `GET`).
- **Response.** `200`: `GrantBulkResponse` — `{results: [{index, status: "created"|"updated"|"error", subject, permission, ...}], total, succeeded, failed}`. Bulk-first: the endpoint returns `200 OK` with per-item results — a 200 doesn't mean every item succeeded.
- **Errors.** `403` insufficient permission; `404` namespace doesn't exist / isn't active; per-item `error` status for exceptions during a single item's write.
- **Example.**
```bash
curl -X POST "https://<host>/api/registry/namespaces/dev/grants" -H "X-API-Key: $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '[{"subject": "alice@example.com", "subject_type": "user", "permission": "write"}]'
```
### `DELETE /api/registry/namespaces/{prefix}/grants`
- **Purpose.** Revoke grants on a namespace (bulk).
- **Request.** Path: `prefix`. Body: `list[GrantRevoke]` — `{subject, subject_type}` (same `api_key` subject normalization as create). Requires namespace-admin permission.
- **Response.** `200`: `GrantRevokeBulkResponse` — `{results: [{index, status: "revoked"|"not_found", subject}], total, succeeded, failed}`. Bulk-first: 200 with per-item results.
- **Errors.** `403` insufficient permission.
- **Example.**
```bash
curl -X DELETE "https://<host>/api/registry/namespaces/dev/grants" -H "X-API-Key: $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '[{"subject": "alice@example.com", "subject_type": "user"}]'
```
### Entries (`components/registry/src/registry/api/entries.py`, mounted at `/entries`)
### `GET /api/registry/entries`
- **Purpose.** Browse registry entries with pagination and filters.
- **Request.** Query: `namespace`, `entity_type`, `status` (`active`|`reserved`|`inactive`), `q` (literal search string — escaped before use, not a regex), `page` (default 1), `page_size` (default 50, max 100). Requires a valid key.
- **Response.** `200`: `BrowseEntriesResponse` — `{items: [BrowseEntryItem], total, page, page_size, pages}`, where each item is `{entry_id, namespace, entity_type, primary_composite_key, synonyms_count, status, created_at, created_by, updated_at}`. Namespace-scoped: an explicit `namespace` the caller can't read returns an empty page (not `403`/`404`) to preserve pagination shape without leaking existence; omitting `namespace` restricts the query to all accessible namespaces.
- **Errors.** None beyond auth.
- **Example.**
```bash
curl "https://<host>/api/registry/entries?namespace=dev&entity_type=documents" -H "X-API-Key: $API_KEY"
```
### `GET /api/registry/entries/search`
- **Purpose.** Unified search across entry IDs, primary composite-key values, and synonym composite-key values — richer than `search/by-fields`/`search/by-term`: returns which field matched and a human-readable resolution path.
- **Request.** Query: `q` (required, min length 1), `namespace`, `entity_type`, `status`, `page` (default 1), `page_size` (default 50, max 100). Requires a valid key. Same namespace-scoping-as-enumeration behavior as `GET /entries` (grant-gated, unlike reference resolution below).
- **Response.** `200`: `UnifiedSearchResponse` — `{items: [UnifiedSearchResultItem], total, page, page_size, query}`. Each item adds `matched_via` (`entry_id` | `composite_key_value` | `synonym_key_value`), `matched_value`, and `resolution_path` (e.g. `"V1-001 → synonym → 019abc42-... (wip-terms)"`) on top of the entry's full fields.
- **Errors.** None beyond auth.
- **Example.**
```bash
curl "https://<host>/api/registry/entries/search?q=V1-001" -H "X-API-Key: $API_KEY"
```
### `GET /api/registry/entries/{entry_id}`
- **Purpose.** Get full detail for a single entry by its `entry_id`.
- **Request.** Path: `entry_id`. Requires a valid key.
- **Response.** `200`: `EntryDetailResponse` — `{entry_id, namespace, entity_type, primary_composite_key, primary_composite_key_hash, synonyms, source_info, search_values, metadata, status, created_at, created_by, updated_at, updated_by}`. Not namespace-filtered — any active or inactive entry is readable by `entry_id` if you know it.
- **Errors.** `404` not found.
- **Example.**
```bash
curl "https://<host>/api/registry/entries/V1-001" -H "X-API-Key: $API_KEY"
```
### `POST /api/registry/entries/register`
- **Purpose.** Register one or more composite keys — sugar for reserve+activate in one call. Returns the existing `entry_id` if the key is already registered.
- **Request.** Body: `list[RegisterKeyItem]` (extra fields rejected) — `{namespace, entity_type, entry_id (optional), composite_key (default {}), identity_values (optional), skip_identity_value_synonym (default false), source_info, created_by, metadata}`. `entity_type` must be one of the five `VALID_ENTITY_TYPES`. All items in one batch must share the same `namespace`.
- **Response.** `200`: `RegisterBulkResponse` — `{results: [RegisterKeyResponse], total, created, already_exists, errors}`, each result `{index, status: "created"|"already_exists"|"error", registry_id, namespace, entity_type, identity_hash, error}`. Bulk-first: 200 with per-item results.
- **Errors.** `422` batch mixes namespaces across items. Per-item `error`: unknown `entity_type`, `namespace` doesn't exist/isn't active, or the provided `entry_id` collides with an existing entry (restore mode requires a clean target).
- **Example.**
```bash
curl -X POST "https://<host>/api/registry/entries/register" -H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '[{"namespace": "dev", "entity_type": "documents", "composite_key": {"template_id": "t1", "field_a": "x"}}]'
```
```json
{"results": [{"index": 0, "status": "created", "registry_id": "019...", "namespace": "dev", "entity_type": "documents"}], "total": 1, "created": 1, "already_exists": 0, "errors": 0}
```
### `POST /api/registry/entries/provision`
- **Purpose.** Provision (generate + reserve) IDs per the namespace's configured algorithm.
- **Request.** Body `ProvisionRequest` (extra fields rejected): `{namespace, entity_type, count (default 1, 1–1000), composite_keys (optional, list), created_by}`.
- **Response.** `200`: `ProvisionResponse` — `{namespace, entity_type, ids: [{entry_id, status: "reserved"}], total}`. Entries are created with `status="reserved"` and must be activated (below) after the caller's own entity create succeeds.
- **Errors.** `400` invalid `entity_type`; `404` namespace not found/not active.
- **Example.**
```bash
curl -X POST "https://<host>/api/registry/entries/provision" -H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" -d '{"namespace": "dev", "entity_type": "documents", "count": 1}'
```
### `POST /api/registry/entries/reserve`
- **Purpose.** Validate and store client-provided IDs as `reserved`, checking format against the namespace's configured algorithm.
- **Request.** Body: `list[ReserveItem]` — `{entry_id, namespace, entity_type, composite_key (optional), created_by}`.
- **Response.** `200`: `ReserveBulkResponse` — `{results: [ReserveItemResponse], total, reserved, errors}`, each `{index, status: "reserved"|"already_exists"|"invalid_format"|"error", entry_id, error}`.
- **Errors.** Per-item: unknown `entity_type`; `entry_id` already exists; `namespace` not found/not active; `entry_id` doesn't match the namespace's configured ID format.
- **Example.**
```bash
curl -X POST "https://<host>/api/registry/entries/reserve" -H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '[{"entry_id": "TERM-000042", "namespace": "dev", "entity_type": "terms"}]'
```
### `POST /api/registry/entries/activate`
- **Purpose.** Activate reserved entries, making them resolvable.
- **Request.** Body: `list[ActivateItem]` — `{entry_id}`.
- **Response.** `200`: `ActivateBulkResponse` — `{results: [ActivateItemResponse], total, activated, errors}`, each `{index, status: "activated"|"not_found"|"already_active"|"error", entry_id, error}`. `already_active` is idempotent-success, not an error.
- **Errors.** Per-item `not_found`; per-item `error` if the entry's current status is neither `reserved` nor `active` (e.g. `inactive`).
- **Example.**
```bash
curl -X POST "https://<host>/api/registry/entries/activate" -H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" -d '[{"entry_id": "TERM-000042"}]'
```
### `POST /api/registry/entries/lookup/by-id`
- **Purpose.** Look up entries by their `entry_id` (bulk); falls back to a `search_values` match if the ID doesn't hit directly (covers merged IDs).
- **Request.** Body: `list[LookupByIdItem]` — `{entry_id, namespace (optional filter), entity_type (optional filter), fetch_source_data (default false)}`.
- **Response.** `200`: `LookupBulkResponse` — `{results: [LookupResponse], total, found, not_found, errors}`. `LookupResponse` includes `matched_via` (`"entry_id"` or `"composite_key_value"`), `synonyms`, `source_info`, and — if `fetch_source_data=true` and the entry has a `source_info.endpoint_url` — a proxied `source_data` fetch (10s timeout; failures degrade to `{"error": "..."}` rather than failing the item). Only `status="active"` entries are resolvable. This is a reference-resolution endpoint — it stays cross-namespace by design, unlike `browse`/`search`.
- **Errors.** Per-item `not_found`; per-item `error` on unexpected exceptions.
- **Example.**
```bash
curl -X POST "https://<host>/api/registry/entries/lookup/by-id" -H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" -d '[{"entry_id": "TERM-000042"}]'
```
### `POST /api/registry/entries/lookup/by-key`
- **Purpose.** Look up entries by composite key (bulk), optionally including synonym keys.
- **Request.** Body: `list[LookupByKeyItem]` — `{namespace, entity_type, composite_key, search_synonyms (default true), fetch_source_data (default false)}`.
- **Response.** `200`: `LookupBulkResponse` (as above). When the match came via a synonym, `matched_namespace`/`matched_entity_type`/`matched_composite_key` reflect the synonym's own values, not the parent entry's.
- **Errors.** Per-item `not_found`/`error`, same shape as `lookup/by-id`.
- **Example.**
```bash
curl -X POST "https://<host>/api/registry/entries/lookup/by-key" -H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '[{"namespace": "dev", "entity_type": "documents", "composite_key": {"template_id": "t1", "field_a": "x"}}]'
```
### `PUT /api/registry/entries`
- **Purpose.** Update mutable fields (`source_info`, `metadata`) on one or more active entries (bulk).
- **Request.** Body: `list[UpdateEntryItem]` — `{entry_id, source_info (optional), metadata (optional — merged into existing via dict update, not replaced), updated_by}`.
- **Response.** `200`: `BulkUpdateResponse` — `{results: [UpdateEntryResponse], total, succeeded, failed}`, each `{index, status: "updated"|"not_found"|"error", registry_id, error}`. Bulk-first: 200 with per-item results.
- **Errors.** Per-item `not_found` (only matches `status="active"` entries); per-item `error`.
- **Example.**
```bash
curl -X PUT "https://<host>/api/registry/entries" -H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '[{"entry_id": "TERM-000042", "metadata": {"note": "renamed"}}]'
```
### `DELETE /api/registry/entries`
- **Purpose.** Deactivate (soft-delete) or permanently remove (hard-delete) one or more entries (bulk).
- **Request.** Body: `list[DeleteItem]` — `{entry_id, hard_delete (default false), rollback_uncommitted (default false), updated_by}`. `rollback_uncommitted` bypasses the `deletion_mode='full'` gate but only for callers whose identity carries the `wip-admins` or `wip-services` group — a trusted write-rollback primitive for aborting a just-allocated entry whose backing object was never committed, not a general escape hatch. `hard_delete` (without the rollback flag) requires the entry's namespace to have `deletion_mode='full'`.
- **Response.** `200`: `BulkDeleteResponse` — `{results: [DeleteResponse], total, succeeded, failed}`, each `{index, status: "deactivated"|"deleted"|"not_found"|"error", registry_id, error}`. Bulk-first: 200 with per-item results.
- **Errors.** Per-item `not_found`; per-item `error` if hard-delete is requested but the namespace's `deletion_mode != 'full'` (message includes the current mode).
- **Example.**
```bash
curl -X DELETE "https://<host>/api/registry/entries" -H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" -d '[{"entry_id": "TERM-000042"}]'
```
### `POST /api/registry/entries/resolve`
- **Purpose.** Resolve synonym composite keys to their canonical `entry_id`, or verify a canonical ID (bulk). If both `entry_id` and `composite_key` are given, `entry_id` is tried first.
- **Request.** Body: `list[ResolveItem]` — `{composite_key (optional), entry_id (optional; at least one of the two required), namespace (optional filter), entity_type (optional filter), include_statuses (optional list; default is active-only)}`.
- **Response.** `200`: `BulkResolveResponse` — `{results: [ResolveResponse], total, found, not_found, errors}`, each `{index, status: "found"|"not_found"|"error", composite_key, entry_id, error}`. Reference-resolution endpoint — stays cross-namespace by design.
- **Errors.** Per-item `error` if neither `entry_id` nor `composite_key` is provided.
- **Example.**
```bash
curl -X POST "https://<host>/api/registry/entries/resolve" -H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" -d '[{"entry_id": "TERM-000042"}]'
```
### Synonyms (`components/registry/src/registry/api/synonyms.py`, mounted at `/synonyms`)
### `POST /api/registry/synonyms/add`
- **Purpose.** Add one or more synonyms to existing active entries (bulk).
- **Request.** Body: `list[AddSynonymItem]` — `{target_id, synonym_namespace, synonym_entity_type, synonym_composite_key, synonym_source_info (optional), created_by}`.
- **Response.** `200`: `BulkSynonymAddResponse` — `{results: [AddSynonymResponse], total, succeeded, failed}`, each `{index, status: "added"|"already_exists"|"target_not_found"|"error", registry_id, error}`. Bulk-first: 200 with per-item results.
- **Errors.** Per-item `target_not_found`; per-item `error` if the synonym's composite-key hash is already claimed by a *different* entry (never auto-stolen).
- **Example.**
```bash
curl -X POST "https://<host>/api/registry/synonyms/add" -H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '[{"target_id": "TERM-000042", "synonym_namespace": "dev", "synonym_entity_type": "terms", "synonym_composite_key": {"old_name": "legacy-term"}}]'
```
### `POST /api/registry/synonyms/remove`
- **Purpose.** Remove a synonym from an entry (bulk).
- **Request.** Body: `list[RemoveSynonymItem]` — `{target_id, synonym_namespace, synonym_entity_type, synonym_composite_key, updated_by}`.
- **Response.** `200`: `BulkSynonymRemoveResponse` — `{results: [RemoveSynonymResponse], total, succeeded, failed}`, each `{index, status: "removed"|"not_found"|"error", registry_id, error}`. Bulk-first: 200 with per-item results.
- **Errors.** Per-item `not_found` (target entry missing, or the synonym isn't on it).
- **Example.**
```bash
curl -X POST "https://<host>/api/registry/synonyms/remove" -H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '[{"target_id": "TERM-000042", "synonym_namespace": "dev", "synonym_entity_type": "terms", "synonym_composite_key": {"old_name": "legacy-term"}}]'
```
### `POST /api/registry/synonyms/merge`
- **Purpose.** Merge two entries (bulk) — the deprecated entry's `entry_id` and all its synonyms become synonyms of the preferred entry; the deprecated entry is deactivated.
- **Request.** Body: `list[MergeItem]` — `{preferred_id, deprecated_id, updated_by}`.
- **Response.** `200`: `BulkMergeResponse` — `{results: [MergeResponse], total, succeeded, failed}`, each `{index, status: "merged"|"preferred_not_found"|"deprecated_not_found"|"error", preferred_id, deprecated_id, error}`. Bulk-first: 200 with per-item results.
- **Errors.** Per-item `preferred_not_found`/`deprecated_not_found`; per-item `error` merging an entry with itself.
- **Example.**
```bash
curl -X POST "https://<host>/api/registry/synonyms/merge" -H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '[{"preferred_id": "TERM-000042", "deprecated_id": "TERM-000099"}]'
```
### Search (`components/registry/src/registry/api/search.py`, mounted at `/search`)
### `POST /api/registry/search/by-fields`
- **Purpose.** Structured search by field-value criteria within composite keys (bulk — one query per item).
- **Request.** Body: `list[SearchItem]` — `{field_criteria: dict, restrict_to_namespaces (optional), restrict_to_entity_types (optional), include_inactive (default false)}`. Namespace-scoped enumeration, per the Conventions section.
- **Response.** `200`: `SearchBulkResponse` — `{results: [SearchResponse]}`, each `{index, status: "ok"|"error", results: [SearchResult], total_matches, error}`. `status` distinguishes a genuine empty result from a per-item failure (a Mongo error and "0 matches" would otherwise look identical). `SearchResult`: `{registry_id, namespace, entity_type, matched_in: "primary"|"synonym", matched_namespace, matched_entity_type, matched_composite_key, all_synonyms}`.
- **Errors.** Per-item `status="error"` (with `error` message) on an unhandled exception — isolated so one bad item doesn't fail the whole batch.
- **Example.**
```bash
curl -X POST "https://<host>/api/registry/search/by-fields" -H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" -d '[{"field_criteria": {"template_id": "t1"}}]'
```
### `POST /api/registry/search/by-term`
- **Purpose.** Free-text search for a term across any field in any composite key (bulk).
- **Request.** Body: `list[SearchByTermItem]` — `{term, restrict_to_namespaces (optional), restrict_to_entity_types (optional), include_inactive (default false), limit (optional, ≥1 — caps hits returned; `total_matches` still reports the true count)}`. Matching is regex-based (the term is escaped, so it's a literal substring match, not caller-supplied regex) — there is no MongoDB text index on `RegistryEntry`.
- **Response.** `200`: `SearchBulkResponse` (same shape as `by-fields`).
- **Errors.** Same per-item `status="error"` isolation as `by-fields`.
- **Example.**
```bash
curl -X POST "https://<host>/api/registry/search/by-term" -H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" -d '[{"term": "legacy", "limit": 20}]'
```
### `POST /api/registry/search/across-namespaces`
- **Purpose.** Same as `search/by-fields`, but forces the search across *all* namespaces regardless of any `restrict_to_namespaces` on the input items — it rebuilds each item with `restrict_to_namespaces=None` and delegates to `search_by_fields`.
- **Request.** Body: `list[SearchItem]` (same shape; `restrict_to_namespaces` is ignored/overridden).
- **Response.** `200`: `SearchBulkResponse` (identical shape to `by-fields`).
- **Errors.** Same as `by-fields`.
- **Example.**
```bash
curl -X POST "https://<host>/api/registry/search/across-namespaces" -H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" -d '[{"field_criteria": {"template_id": "t1"}}]'
```
### My Permissions (`components/registry/src/registry/api/grants.py`, mounted at `/my`)
### `GET /api/registry/my/namespaces`
- **Purpose.** List all active namespaces the caller has access to, with their resolved permission level.
- **Request.** No params. Requires a valid key.
- **Response.** `200`: `list[MyNamespaceResponse]` — `{prefix, description, permission: "read"|"write"|"admin"}`.
- **Errors.** None beyond auth.
- **Example.**
```bash
curl "https://<host>/api/registry/my/namespaces" -H "X-API-Key: $API_KEY"
```
### `GET /api/registry/my/namespaces/{prefix}/permission`
- **Purpose.** Get the caller's own permission level on a specific namespace.
- **Request.** Path: `prefix`. Requires a valid key.
- **Response.** `200`: `{namespace, permission}`.
- **Errors.** `404` if the namespace doesn't exist, or the caller has no access — both cases return the same 404, to avoid leaking namespace existence.
- **Example.**
```bash
curl "https://<host>/api/registry/my/namespaces/dev/permission" -H "X-API-Key: $API_KEY"
```
### `GET /api/registry/my/check-permission`
- **Purpose.** Internal, service-to-service: resolve a *different* user's permission on a namespace (the caller is a trusted service reconstructing that user's identity, not the user itself).
- **Request.** Query: `namespace`, `user_id`, `email` (optional), `username` (optional), `groups` (optional, legacy CSV — prefer the `X-User-Groups` header), `auth_method` (default `"jwt"`; one of `jwt`, `api_key`, `gateway_oidc`, `none`). Reads `X-User-Groups` and `X-Key-Namespaces` headers to reconstruct the target identity. Requires a valid key belonging to `wip-admins` or `wip-services`.
- **Response.** `200`: `{namespace, user_id, permission}`.
- **Errors.** `403` caller isn't in `wip-admins`/`wip-services`.
- **Example.**
```bash
curl "https://<host>/api/registry/my/check-permission?namespace=dev&user_id=u123" \
-H "X-API-Key: $SERVICE_KEY" -H "X-User-Groups: editors"
```
### `GET /api/registry/my/accessible-namespaces`
- **Purpose.** Internal, service-to-service: get a *different* user's full list of accessible namespaces.
- **Request.** Same query/header shape as `check-permission` (minus `namespace`). Requires `wip-admins`/`wip-services`.
- **Response.** `200`: superadmin → `{namespaces: null, is_superadmin: true}` (meaning "no restriction"); otherwise `{namespaces: [prefix, ...], is_superadmin: false}`.
- **Errors.** `403` caller isn't privileged.
- **Example.**
```bash
curl "https://<host>/api/registry/my/accessible-namespaces?user_id=u123" \
-H "X-API-Key: $SERVICE_KEY" -H "X-User-Groups: editors"
```
### API Keys (`components/registry/src/registry/api/api_keys.py`, mounted at `/api-keys`)
### `POST /api/registry/api-keys`
- **Purpose.** Create a new runtime API key. The plaintext is returned once and never stored (only its hash is).
- **Request.** Body `APIKeyCreateRequest` (extra fields rejected): `{name, owner (default "system"), groups (default []), description, expires_at, namespaces (null = unrestricted), grant_permission ("read"|"write"|"admin", optional)}`. `grant_permission` requires `namespaces` to be set. Requires an admin-scoped key.
- **Response.** `201`: `APIKeyCreatedResponse` — `APIKeyResponse` fields (`name, owner, groups, description, created_at, expires_at, enabled, namespaces, created_by, source="runtime"`) plus `plaintext_key` and `granted_namespaces` (set only when `grant_permission` was used — creates a matching `NamespaceGrant` per namespace, subject = the key's bare name).
- **Errors.** `422` `grant_permission` without `namespaces`. `409` name collides with a config-file key or an existing runtime key.
- **Example.**
```bash
curl -X POST "https://<host>/api/registry/api-keys" -H "X-API-Key: $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "ci-bot", "namespaces": ["dev"], "grant_permission": "write"}'
```
### `GET /api/registry/api-keys`
- **Purpose.** List all API keys (config-file + runtime), paginated.
- **Request.** Query: `page` (default 1), `page_size` (default 50, max 100). Requires an admin-scoped key.
- **Response.** `200`: `APIKeyListResponse` — `{items: [APIKeyResponse], total, page, page_size, pages}`, sorted by `name`. No hashes returned. `APIKeyResponse.source` distinguishes `"config"` vs `"runtime"`.
- **Errors.** None beyond auth.
- **Example.**
```bash
curl "https://<host>/api/registry/api-keys" -H "X-API-Key: $ADMIN_KEY"
```
### `GET /api/registry/api-keys/sync`
- **Purpose.** Sync endpoint for service-side key polling — the only endpoint that returns key hashes.
- **Request.** No params. Requires a key in the `wip-services` or `wip-admins` group (`require_groups`, not `require_admin_key`).
- **Response.** `200`: `list[APIKeySyncRecord]` — `{name, key_hash, owner, groups, description, created_at, expires_at, enabled, namespaces}`. Only *runtime* keys with `enabled=true` — config-file keys are excluded (each service already loads those from its own config).
- **Errors.** `403` (via the group dependency) if the caller isn't in `wip-services`/`wip-admins`.
- **Example.**
```bash
curl "https://<host>/api/registry/api-keys/sync" -H "X-API-Key: $SERVICE_KEY"
```
### `GET /api/registry/api-keys/{name}`
- **Purpose.** Get metadata for a single API key (config or runtime).
- **Request.** Path: `name`. Requires an admin-scoped key.
- **Response.** `200`: `APIKeyResponse`.
- **Errors.** `404` not found in either config or runtime keys.
- **Example.**
```bash
curl "https://<host>/api/registry/api-keys/ci-bot" -H "X-API-Key: $ADMIN_KEY"
```
### `PATCH /api/registry/api-keys/{name}`
- **Purpose.** Update metadata on a runtime API key.
- **Request.** Path: `name`. Body `APIKeyUpdateRequest` (extra fields rejected): `{description, groups, namespaces, expires_at, enabled}` — only fields provided are applied. Requires an admin-scoped key.
- **Response.** `200`: `APIKeyResponse`. The in-memory key provider is updated in lockstep (old hash removed, updated record re-added) so the change takes effect immediately.
- **Errors.** `400` target is a config-file key (cannot modify via API); `404` runtime key not found.
- **Example.**
```bash
curl -X PATCH "https://<host>/api/registry/api-keys/ci-bot" -H "X-API-Key: $ADMIN_KEY" \
-H "Content-Type: application/json" -d '{"enabled": false}'
```
### `DELETE /api/registry/api-keys/{name}`
- **Purpose.** Hard-delete (revoke) a runtime API key.
- **Request.** Path: `name`. Requires an admin-scoped key.
- **Response.** `200`: `{status: "deleted", name}`.
- **Errors.** `400` target is a config-file key (cannot delete via API); `404` runtime key not found.
- **Example.**
```bash
curl -X DELETE "https://<host>/api/registry/api-keys/ci-bot" -H "X-API-Key: $ADMIN_KEY"
```
## Store-specific gotchas
- **`entry_id` is globally unique; the primary composite key is not.** `entry_id_unique_idx` has no namespace scoping, but `namespace_entity_keyhash_unique_idx` scopes composite-key uniqueness to `(namespace, entity_type, hash)` — the same composite key in two different namespaces yields two distinct entries.
- **Synonyms live in a separate uniqueness domain from the embedded array they're stored in.** Synonyms are embedded subdocuments on `RegistryEntry` (`registry/models/entry.py`), which a MongoDB unique index can't guard directly. The registry maintains a standalone `CompositeKeyClaim` collection (`registry/models/composite_key_claim.py`) as the write-time uniqueness gate for both primary and synonym keys, keyed by `(namespace, entity_type, composite_key_hash)` — the read model stays the embedded `synonyms` array, but the claim collection is what actually rejects a colliding key.
- **`identity_hash` is computed from raw values, then folded into the dedup key.** When `register`'s `identity_values` is supplied, the registry hashes those raw values into `identity_hash`, injects it into `composite_key`, and *then* hashes the full `composite_key` for dedup — the identity value itself becomes part of what's deduped on.
- **Enumeration is grant-gated; reference resolution isn't.** `browse`, unified `search`, and `search/by-fields`/`search/by-term` all restrict to namespaces the caller has a grant on. `lookup/by-id`, `lookup/by-key`, and `resolve` stay cross-namespace by design — a caller can resolve a reference into a namespace it can't list.
- **Locked namespaces are inaccessible to *everyone*, including superadmins.** `_resolve_permission` checks `status == "locked"` before the superadmin bypass — a namespace mid-deletion returns `"none"` for any caller, not just unprivileged ones.
- **`register` batches must share one namespace.** Mixing namespaces across items in a single `POST /entries/register` call is a hard `422`, not a per-item error — this validation runs before any per-item processing.
- **Hard-delete requires `deletion_mode='full'`, which the default `wip` namespace can never take.** New namespaces default to `deletion_mode='retain'`; flipping an existing one to `full` needs `confirm_enable_deletion=true` in the same call. `rollback_uncommitted` on `DELETE /entries` is a separate, narrower bypass of that gate — honored only for `wip-admins`/`wip-services` callers, for aborting a just-allocated entry that never got committed (not a general escape hatch).
- **Namespace deletion's `dry_run=true` bypasses the `deletion_mode='full'` check.** A dry-run impact report can be requested on a `retain`-mode namespace; only the real delete enforces the mode gate.
- **Namespace deletion can finish "successfully" while having silently left data behind.** `completed_with_warnings` is a terminal, non-`failed` status — it means every step ran, but at least one best-effort step against MinIO or PostgreSQL degraded (MongoDB is the system of record and isn't allowed to degrade silently the same way). Callers checking only for `status == "completed"` will miss this.
- **`q`/`term` search parameters are literal strings, not caller-supplied regex.** `browse`'s `q`, unified `search`'s `q`, and `search/by-term`'s `term` are all regex-escaped before being used in a MongoDB `$regex` query.
- **Request bodies reject unknown fields.** Every `StrictModel`-based request (`RegisterKeyItem`, `NamespaceCreate`, `ProvisionRequest`, `ReserveItem`, `ActivateItem`, `AddSynonymItem`, `RemoveSynonymItem`, `MergeItem`, `LookupByIdItem`, `LookupByKeyItem`, `ResolveItem`, `SearchItem`, `SearchByTermItem`, `UpdateEntryItem`, `DeleteItem`, `APIKeyCreateRequest`, `APIKeyUpdateRequest`) has `extra='forbid'` — a typo'd field name is a `422`, not a silently-ignored no-op.
- **Config-file API keys (`wip-admins`, `wip-services`) are read-only through this API.** They can be listed and fetched by name, but never created, updated, or deleted here — only the Mongo-backed runtime keys support write operations.
## See also
- [Identity & the Registry](/docs/identity-and-the-registry/) (the concept doc for the entry/composite-key/synonym identity model this API exposes)
- [Namespaces & Tenancy](/docs/namespaces-and-tenancy/) (the concept doc for namespace lifecycle, isolation, and deletion semantics)
- [Data Model Reference](/docs/data-model-reference/)
- [API Conventions](/docs/api-conventions/)
- [PoNIFs](/docs/ponifs/)
- [Auth & Access](/docs/auth-and-access/)
- [Routing & Ingress](/docs/routing-and-ingress/)
- [Document-Store API](/docs/document-store-api/)
- [Template-Store API](/docs/template-store-api/)
- [Def-Store API](/docs/def-store-api/)
Generated from real source code, not hand-written — see the WIP Technical Library.
