API Conventions
API Conventions
In one sentence. API Conventions is the set of cross-cutting rules — bulk-write semantics, identity/versioning, soft-delete-by-default, namespace-scoped authorization, response caching windows, and pagination — that every WIP REST and MCP surface follows the same way, so a caller who has read this doc once does not need to re-derive it per store.
Why it exists. WIP exposes several independently-implemented stores (document-store, template-store, def-store, registry) plus an MCP layer over them. The conventions exist so callers of any of them get identical semantics for writes, versioning, deletion, and authorization without deriving them per-endpoint, and so idempotent bootstrap scripts can provision a namespace, its templates, and its vocabularies against a fresh — or partially-provisioned — instance and simply be re-run on failure, "without ugly GET → 404 → POST dances or silent schema drift" (wip://conventions, "Idempotent Bootstrap"). The on_conflict=validate design explicitly favors loud failures over silent ones: "silent guardrails are worse than loud ones" (wip://conventions, "Template create with conflict validation").
The model
WIP has one identity mechanism per entity type; none of them are interchangeable:
| Entity | Identity | Versioning behaviour |
|---|---|---|
| Template | template_id + version | Updating creates a new version; the previous version stays active — not superseded — until explicitly deactivated (deactivate_template). Multiple versions can accept documents simultaneously. |
| Document | the template's declared identity_fields, hashed | Same hash → new version of the same document_id (update); different hash → new document (create). create_document is the same call for both — an upsert. Zero identity_fields = append-only: every submission is a new document, and PATCH is rejected outright. |
| Term | (namespace, terminology_id, value) | Unique within its terminology; no separate versioning axis described in scope. |
Bulk write responses carry one status per submitted item — a 200 OK on the envelope says nothing about any individual item:
| Status | Meaning |
|---|---|
created | New entity written |
updated | Existing entity versioned (or overwritten in place, for versioned: false templates) |
unchanged | No-op — identical resubmission, or an empty/no-op PATCH |
skipped | Bulk-only: duplicate term create (2+ items) skipped rather than erroring |
error | Failed; carries a machine-readable error_code alongside the human error message |
PATCH (update_document) applies an RFC 7396 JSON Merge Patch and can fail with any of: not_found, forbidden, archived, identity_field_change, append_only, concurrency_conflict, validation_failed, reference_violation, internal_error.
Namespace authorization is a two-axis model: a permission tier (read / write / admin grant, or superadmin) and an isolation mode governing what the namespace's documents may reference:
| Isolation mode | A document in this namespace may reference |
|---|---|
open (default) | own namespace + the wip namespace + allowed_external_refs |
strict | own namespace + explicit allowed_external_refs only — wip is not automatic |
How it works
⚠ GAP: this doc's
source_scopeiswip://conventions(policy text) plus exactly one implementing module, the auth middleware. The store-level code that actually loops over bulk items, resolves template/document versions, or paginates a list response is not in scope — those live per-store (document-store, template-store, def-store, registry), each with its own doc in this library. This section documents the one mechanism whose implementation is in scope: authentication.
AuthMiddleware.dispatch (libs/wip-auth/src/wip_auth/middleware.py:69-121) runs on every request:
- Public-path short-circuit. If
request.url.pathexactly matches an entry inself.public_paths— the universal set{"/", "/health", "/ready", "/docs", "/redoc", "/openapi.json"}plus whatever service-specific paths were passed to the constructor — the middleware sets the current identity toNone, calls the downstream handler, and resets. No provider runs, so a stale or wrong credential header on a public path cannot 401 it. - Provider iteration. For any other path, the middleware tries
self.providersin the order given to the constructor. The first provider whoseauthenticate(request)returns a non-Noneidentity wins; iteration stops there. - Provider exception short-circuit. If a provider raises
HTTPException(e.g., invalid credentials), the middleware converts it directly into aJSONResponsewith the samestatus_code,detail, andheaders, and returns immediately — remaining providers in the list are never tried. - No-match is not a rejection. If no provider returns an identity,
identitystaysNoneand the request still proceeds tocall_next. The middleware itself never rejects an unauthenticated request; that is left to route-level dependencies (e.g.require_identity), which is what lets public and protected routes share one app and one middleware stack. - Context propagation. Whatever
identityresolved to (Noneor aUserIdentity) is stored viaset_current_identity(identity), which returns atoken;call_nextruns with that context set;reset_current_identity(token)restores the prior value in afinallyblock. The module notes this token-based save/restore exists specifically so a nested in-process call (e.g., the Registry called viaASGITransport) doesn't wipe the outer request's identity.
create_auth_middleware(providers, public_paths) (middleware.py:124-154) is the factory a service actually wires up: it returns a middleware subclass with providers and public_paths pre-bound for app.add_middleware(...). The module's own usage example pairs an API-key provider with an OIDC provider: providers = [APIKeyProvider(keys), OIDCProvider(issuer_url="...")].
Rules & invariants
- Bulk-first, always 200. Every write endpoint accepts arrays and returns 200 OK with one status per item — a caller must branch on
results[i].status/error_code, never infer success from the outer status code. This is a WIP PoNIF — see PoNIFs. - Templates keep old versions live. Updating a template does not retire the version it replaces — both stay active and can validate documents simultaneously until the caller explicitly deactivates the old one. Creating a document without an explicit
template_versionresolves to "latest active," which is ambiguous whenever more than one version is active. Also a PoNIF — see PoNIFs. - Identity fields are the only dedup key, and they're all-or-nothing. Too few identity fields under-distinguish records; too many make every near-duplicate a distinct document; timestamps must never be identity fields (guarantees uniqueness, defeats dedup entirely) and should be avoided even outside identity fields (they force unnecessary version churn on otherwise-unchanged documents). A template with zero identity fields has no update path at all: every
create_documentcall appends a new document, and PATCH always fails withappend_only. - PATCH cannot change identity.
update_documentdeep-merges objects, replaces arrays wholesale, and treatsnullas field deletion — but any attempt to change an identity field is rejected (identity_field_change); to change identity you POST a new document instead. A patched version preserves thetemplate_versionandidentity_hashrecorded on the document it patched, so it validates against that version, not the template's current latest. on_conflict=validatehas an asymmetric compatibility rule. For templates: identical schema isunchanged; adding an optional field isupdated(new version,is_new_version=true); anything else (added required field, removed field, changed type, made-required, or an identity change) iserrorwitherror_code=incompatible_schemaand adetailsbreakdown (added_required,removed,changed_type,made_required,modified_existing,identity_changed). For terminologies/terms there is no partial-compatibility tier: any config difference underon_conflict=validateiserror(incompatible_config); only an exact match isunchanged.- Reference comparison at conflict-check time resolves synonyms.
terminology_ref,template_ref,target_templates,target_terminologies,array_terminology_ref,array_template_ref(and the template-levelsource_templates/target_templateson relationship templates) are compared through the Registry first, so a value-form reference and its UUID synonym are treated as identical — a value↔UUID difference alone never tripsmodified_existing. - Namespace
PUTis an idempotent upsert with deletion-mode guardrails. Creating on a missing prefix uses platform defaults (isolation_mode='open',deletion_mode='retain',description='', emptyallowed_external_refs); the defaultwipnamespace can never be flipped todeletion_mode='full'; flipping an existing namespace fromretaintofullrequiresconfirm_enable_deletion=truein the same request; a brand-new namespace may be created directly atdeletion_mode='full'with no confirmation step. - Soft delete is the default, with two independent hard-delete exceptions. Under
deletion_mode='retain', deletes only flipstatustoinactive— nothing is physically removed.hard_delete=truerequiresdeletion_mode='full'. Binary files (storage reclaim) and terms in mutable terminologies bypass this and can be hard-deleted regardless of the namespace'sdeletion_mode. An inactive term or document is still resolvable by anything that already references it, but is rejected as a new value — only active terms validate on write. - No grant on a namespace looks identical to a missing namespace. A caller without a read/write/admin grant gets 404, not 403 — existence is not leaked. Superadmins (the
wip-adminsgroup) bypass grant checks entirely. Also a PoNIF — see PoNIFs. - Relationship documents are the one exception to
allowed_external_refs. For a relationship-usage (edge) document,source_ref/target_refmust resolve inside the edge's own namespace regardless ofisolation_modeorallowed_external_refs— a cross-namespace edge is rejected (error_code=cross_namespace_relationship). The trade-off of using a plain document-reference field instead (to link across namespaces) is losing the/relationshipsand/traverseendpoints and the edge reporting columns same-namespace edges get. Reference validation itself runs at document-creation time, not template-creation time. - API keys without an explicit namespace list have no access. A non-privileged key lacking a
namespaceslist (and not inwip-admins/wip-services) sees every namespace as 404. A single-namespace key gets its namespace derived automatically when the caller omits it; a multi-namespace key must passnamespaceexplicitly on every call, or raw values pass through with no synonym resolution at all. - Two independent 5-second caches. Template "latest"-version resolution and namespace isolation-config reads (
isolation_mode,allowed_external_refs) each carry a 5-second TTL; lookups by explicit template version are cached permanently since versions are immutable. A namespace allow-list change, or a template update, can take up to 5 seconds to become visible to a retried call. - Pagination is fixed-shape. Default
page_sizeis 50, max is 100; every list response includes a computedpages = ceil(total / page_size).
Gotchas
- A 200 OK from any bulk write can still contain per-item failures — check
results[i].statusbefore assuming anything succeeded. - Updating a template doesn't deactivate what it replaces; two versions of the same template can be live and accepting documents at once unless you deactivate the old one yourself.
- A template with no
identity_fieldshas no correction path — PATCH always failsappend_only; the only way to "fix" a bad submission is a brand-new document. - Multi-namespace API keys that omit
namespacedon't error — they silently skip synonym resolution and pass raw values through. - After widening a namespace's
allowed_external_refs, a write that was previously rejected can keep failing for up to 5 seconds — the namespace-config cache, not a propagation bug. isolation_mode='open'with a matchingallowed_external_refsentry still won't let a relationship document'ssource_ref/target_refcross namespaces — that allow-list governs plain reference fields only, not edges.- The auth middleware's
public_pathsmatch is exact-string, not prefix: an API-prefixed health route like/api/registry/healthis not covered by the universal/healthentry — it has to be added explicitly, or the route requires a valid credential like any other. - Provider order in
AuthMiddlewareis significant and not "try all and merge": the first provider to return an identity wins, and the first provider to raiseHTTPExceptionends the whole chain — a later provider never gets a chance once an earlier one throws.
