{"openapi":"3.1.0","info":{"title":"CLTCH Integration API (mock)","summary":"Mock of the CLTCH side of the Phenome ⇄ CLTCH integration.","description":"Mock of the **CLTCH** side of the Phenome ⇄ CLTCH integration.\n\n> **Reading this spec.** Every operation's tag says its **role scope** — whether it's\n> *universal* (implemented identically on Phenome **and** CLTCH) or specific to one\n> side. This spec lists only the endpoints **CLTCH** exposes; the\n> table below shows the full picture across both services.\n\n### Endpoint scope (both services)\n\n| Endpoint | Phenome | CLTCH | Scope |\n|---|:---:|:---:|---|\n| `POST /v1/oauth/authorize` | — | ✅ | **CLTCH only** (OAuth AS — user consents) |\n| `POST /v1/oauth/token` | — | ✅ | **CLTCH only** (OAuth AS — provider redeems the code) |\n| `POST /v1/link/disconnect` | ✅ | ✅ | **Universal** (sever a link, however it was created) |\n| `POST /v1/animals/link` | ✅ | ✅ | **Universal** (symmetric; exchanges results ↔ genetics + establishes the ref) |\n| `GET /v1/animals` | — | ✅ | **CLTCH only** (animal registry) |\n| `POST /v1/results` | — | ✅ | **CLTCH only** (ongoing result updates) |\n| `GET /v1/animals/{phenomeAnimalId}/results` | ✅ | — | **Phenome only** (genetics authority) |\n| `GET /v1/morphs` | ✅ | — | **Phenome only** (morph vocabulary) |\n\n\n**Auth:** every `/v1` call requires `Authorization: Bearer <api_key>` (one opaque key\nper partner; no JWT, no HMAC) — except the OAuth linking endpoints below, which have\ntheir own auth (a CLTCH user Bearer token, or client credentials in the body).\n\n### Linking a user account — OAuth 2.0 (Authorization Code + PKCE)\n\n> **A user can start this from EITHER side** — a Phenome user clicking \"Connect to\n> CLTCH\", or a CLTCH user clicking \"Connect to Phenome\", both end up completing the\n> same link. That is a separate question from which SERVER hosts the two HTTP\n> endpoints below — see the note right after them.\n\n`POST /v1/oauth/authorize` and `POST /v1/oauth/token` are implemented **only by\nCLTCH** — CLTCH is the OAuth authorization server (it owns the accounts being\nconsented over); a genetic test provider such as Phenome is the OAuth **client** and\nnever implements these endpoints itself, it calls them on CLTCH. **This is a\nprotocol-role fact, not a restriction on who may start the flow** — exactly like\n\"Sign in with Google\" always calls Google's `/authorize` and `/token` no matter which\napp's button the user clicked first. That's why these two endpoints appear as live,\ncallable operations only in **CLTCH's** spec (see its tag \"OAuth account linking\").\nThis section documents them on **both** specs — Phenome needs the full contract to\nknow what to call, even though it doesn't serve these paths itself.\n\n1. **`POST /v1/oauth/authorize`** *(CLTCH implements; called by CLTCH's own\n   frontend once the logged-in CLTCH user has consented — requires a CLTCH user\n   Bearer token, not the company API key)*:\n\n   ```jsonc\n   // request\n   { \"geneticTestProviderId\": \"a7d8fd6f-1515-4d7a-8471-4d3105dc03ba\",\n     \"redirectUri\": \"http://localhost:12345/ui/oauth/callback\",\n     \"scope\": \"genetics:share\", \"state\": \"xyz123\",\n     \"codeChallenge\": \"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM\",\n     \"codeChallengeMethod\": \"S256\" }\n   // 200 response\n   { \"code\": \"a1b2c3d4e5f6\", \"state\": \"xyz123\" }\n   ```\n\n2. **`POST /v1/oauth/token`** *(CLTCH implements; called by the genetic test\n   provider — e.g. Phenome — authenticating via `geneticTestProviderId` /\n   `clientSecret` in the body, not a Bearer token)*:\n\n   ```jsonc\n   // request\n   { \"geneticTestProviderId\": \"a7d8fd6f-1515-4d7a-8471-4d3105dc03ba\",\n     \"clientSecret\": \"<issued to the provider>\", \"code\": \"a1b2c3d4e5f6\",\n     \"codeVerifier\": \"<PKCE verifier, kept secret by the provider>\",\n     \"providerUserId\": \"ext-user-123\",\n     \"redirectUri\": \"http://localhost:12345/ui/oauth/callback\" }\n   // 200 response\n   { \"id\": \"5781bb7d-8c61-4472-bd2b-7dd3465258a9\",\n     \"geneticTestProviderId\": \"a7d8fd6f-1515-4d7a-8471-4d3105dc03ba\",\n     \"geneticTestProviderName\": \"Phenome\", \"providerUserId\": \"ext-user-123\",\n     \"consentToShareGenetic\": true }\n   ```\n\n(Mechanically: when a CLTCH user clicks first, CLTCH's dashboard just hands the\nbrowser to Phenome's own client-start page, and the identical standard flow above\nruns from there — so the two HTTP calls are always CLTCH-hosted / Phenome-called no\nmatter which dashboard the user started on.) `scope` carries the one-way\ngenetics-sharing consent (`\"genetics:share\"` present or absent) — only CLTCH's own\nconsent screen decides the value that's actually recorded, regardless of what the\nclient requested. The returned `id` is an opaque handle (never CLTCH's internal user\nid) that the provider must remember and present back as `partnerUserId` on future\ncalls (`GET /v1/animals`,\n`POST /v1/animals/link`).\n\n","version":"v1"},"paths":{"/v1/link/disconnect":{"post":{"tags":["User account unlinking · BOTH services"],"summary":"Sever account link(s) — purge any link touching the given id(s)","description":"**Scope: universal.** Implemented identically on both services.\n\n**How it works.** `requestingUserId` is required; `targetUserId` is optional. The\nreceiver **purges every Account Link that references either supplied id** (in either\nrole), not just the one link between the exact pair:\n* With **only `requestingUserId`** → removes the link for that id whatever it's paired\n  with.\n* With **both ids** → removes any link involving `requestingUserId` *and* any link\n  involving `targetUserId`, even if they're currently paired with *different* partners.\n\n**Idempotent** — returns `disconnected` with a `removed` count (0 if nothing matched).\n\n**Why it's built this way.** The relationship is strictly 1:1, so the caller is really\nasserting *\"these ids are no longer linked to anyone.\"* If the two sides' state has\ndrifted — e.g. an id ended up paired with two different partners after a failed\nmid-flight change — a match-the-exact-pair delete would leave the mess in place. Purging\n**every** link that touches the named ids drives the system back toward a clean, single\n1:1 (or unlinked) state rather than preserving an inconsistency. Trusted service-to-\nservice, so no user re-confirm. Already-synced genetic data is a self-contained copy on\nthe receiver and is left intact — unlinking stops future sharing, it doesn't delete data\nalready delivered.","operationId":"link_disconnect_v1_link_disconnect_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}]}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LinkDisconnect"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LinkDisconnectResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/health":{"get":{"tags":["Mock utilities · NOT part of the contract"],"summary":"Liveness probe","description":"**Mock-only — not part of the integration contract.** Returns this mock's role, display name, and configured partner base URL. Handy for checking a dev/mock is up and pointed at the right partner.","operationId":"health_health_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/_mock/reset":{"post":{"tags":["Mock utilities · NOT part of the contract"],"summary":"Wipe and reseed this mock's database","description":"**Mock-only — not part of the integration contract.** Drops every table and re-seeds the fixture users/animals/results, returning the mock to a known state for a fresh demo. A real service has no such endpoint.","operationId":"reset__mock_reset_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/_mock/state":{"get":{"tags":["Mock utilities · NOT part of the contract"],"summary":"Dump this mock's current stored state","description":"**Mock-only — not part of the integration contract.** Returns every stored row (users, OAuth provider registry/codes/pending requests, account links, animals, submissions, results, exchange log) as JSON, so you can inspect exactly what the mock recorded after a flow. A real service has no such endpoint.","operationId":"state__mock_state_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/v1/oauth/authorize":{"post":{"tags":["OAuth account linking · CLTCH only (Phenome is the OAuth client)"],"summary":"Cltch user consents to link a genetic test provider","description":"**Scope: CLTCH only.** CLTCH is the OAuth authorization server for this integration —\nit's the one asking \"does this user want to link that provider?\"\n\n**How it works.** Called by CLTCH's **own frontend**, after the logged-in CLTCH user\nhas seen and approved a consent screen — requires a CLTCH user Bearer token (proof of\nthat login), not the company-level API key used everywhere else in this contract.\nLooks up the named `geneticTestProviderId`, checks the supplied `redirectUri` against\nthe exact set that provider registered out-of-band (`400 INVALID_REDIRECT_URI` if it's\nnot one of them), then mints a one-time authorization code bound to this user +\nprovider + `redirectUri` + the supplied PKCE `codeChallenge`, and returns it plus the\nechoed `state`. The code expires in 5 minutes and is single-use.\n\n`scope` carries the one-way genetics-sharing consent (see §8 of the integration\nspec): `\"genetics:share\"` present means the checkbox was on. There's no separate\nconsent field — this is exactly what OAuth `scope` is for, and only CLTCH's consent\nscreen decides the value that ends up here.\n\n**Why it's built this way.** Because only CLTCH owns structured genetics to\nwithhold, only CLTCH needs to be the party a provider's OAuth flow redirects to for\nconsent — the provider never sees CLTCH credentials, and CLTCH never hands out an\naccess token that would grant it broader access to CLTCH systems (see `/v1/oauth/token`).","operationId":"oauth_authorize_v1_oauth_authorize_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}]}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthAuthorizeRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthAuthorizeResponse"}}}},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/oauth/token":{"post":{"tags":["OAuth account linking · CLTCH only (Phenome is the OAuth client)"],"summary":"Genetic test provider exchanges a code for an account link","description":"**Scope: CLTCH only.** Called by the genetic test provider — **not** a Cltch user —\nto finish establishing the link between the CLTCH user, this provider, and the\nprovider's own user id.\n\n**How it works.** Authenticates with the client id and secret CLTCH issued, presented\neither as `Authorization: Basic base64(clientId:clientSecret)` (RFC 6749 §2.3.1, the\nstandard form) or as `geneticTestProviderId` + `clientSecret` in the body — not a Bearer\ntoken, since the caller is a company rather than a logged-in person. Validates\nthe authorization code: not expired, not already redeemed, issued to this same\nprovider and `redirectUri`, and that `sha256(codeVerifier)` (base64url, no padding)\nmatches the `codeChallenge` captured at `/v1/oauth/authorize` time (PKCE — proves the\nparty redeeming the code is the same one that started the request, even though the\ncode briefly transited the user's browser). The code is deleted on success (single-use).\n\nCreates or updates CLTCH's Account Link and returns it. **`id` is a freshly-opaque\nhandle** — never CLTCH's own internal user id — that the provider must remember and\npresent as `partnerUserId` on every future call (`GET /v1/animals`,\n`POST /v1/animals/link`). `consentToShareGenetic` reflects the `scope` captured at\nauthorize time.\n\n**Why it's built this way.** This is the standard OAuth Authorization Code + PKCE\nexchange: a code that only ever appears in a redirect (and so could leak through\nbrowser history/referrers/logs) is not by itself enough to claim the link — only the\nparty holding the original `codeVerifier` can complete it. Returning an opaque `id`\nrather than CLTCH's real user id means CLTCH never has to hand its internal\nidentifiers to a third party, and this grant carries **no further access** to CLTCH\nsystems beyond identifying the link.","operationId":"oauth_token_v1_oauth_token_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}]}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthTokenRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthTokenResponse"}}}},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/animals":{"get":{"tags":["Animal search · CLTCH only"],"summary":"Search a linked user's animals (paginated)","description":"**Scope: CLTCH only.** CLTCH is the animal-registry authority; Phenome calls this at\norder time. (There is no equivalent on Phenome.)\n\n**How it works.** The caller passes `partnerUserId` — *its own* id for the customer —\nwhich the receiver resolves through the Account Link to the local CLTCH user, then\nreturns that user's ball pythons. Supports `q` (case-insensitive match on name/title),\n`page`, and `pageSize`. Each item carries the **stable animal id** (the correlation\nkey used everywhere else), `name`, `title`, `sex`, and `genetics` — but `genetics` is\n`null` unless the user consented to share it. `403 NOT_LINKED` if the caller has no\nAccount Link for that user.\n\n**Why it's built this way.** At order time Phenome shows the customer's real animals\ninstead of free-text entry, eliminating mismatches. Accounts can hold tens of thousands\nof animals, so full-list retrieval isn't viable — hence server-side search + paging.\nGenetics are consent-gated per the privacy model. The endpoint only ever returns the\n*linked* user's animals, so a company credential alone can't reach another user's\ndata.","operationId":"search_animals_v1_animals_get","parameters":[{"name":"partnerUserId","in":"query","required":true,"schema":{"type":"string","description":"The CALLER's user id on its own platform"},"description":"The CALLER's user id on its own platform"},{"name":"q","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Case-insensitive match on name/title"},"description":"Case-insensitive match on name/title"},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":1}},{"name":"pageSize","in":"query","required":false,"schema":{"type":"integer","maximum":200,"minimum":1,"default":20}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}]}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnimalSearchResponse"}}}},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/animals/link":{"post":{"tags":["Animal linking · BOTH services"],"summary":"Link an animal + exchange genetics/results (symmetric)","description":"**Scope: universal — symmetric.** Either side may call the other to link an animal. Each\nside **sends the data it's the authority for and receives the other's**; the receiver\nverifies its local animal exists (`404 ANIMAL_NOT_FOUND`) and the owning accounts are\nlinked (`403 ACCOUNT_NOT_LINKED`). Trusted/one-way — no reciprocal confirm.\n\n**Direction matters:**\n* **Phenome → CLTCH** — Phenome sends its `testResults` and **omits** `submissionRef`.\n  CLTCH stores the results, **mints a `submissionRef`**, and returns it plus its\n  `knownGenetics` (only if the user consented; otherwise an empty array).\n* **CLTCH → Phenome** — CLTCH sends its `knownGenetics` (consent-gated) **and** a\n  `submissionRef`. Phenome records the ref, and returns all `testResults` it holds for\n  the animal.\n\nPhenome **never** sends structured `knownGenetics` — on Phenome that field is free-text\nonly, so it can't be turned into the structured model CLTCH uses.\n\n**Why it's built this way.** One symmetric primitive means either product can initiate\nlinking and the two exchange exactly what each owns in a single round-trip. Because the\nref is **established at link time and lives for the life of the linkage** (see the\ndecision in SPEC_GAPS), Phenome always ends up holding a `submissionRef` for every linked\nreptile — which it echoes on every subsequent `POST /v1/results`. Animals are keyed on\nstable ids (never names); linking is gated on the accounts being linked so a link can't\nleak across users.","operationId":"animal_link_v1_animals_link_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}]}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnimalLinkRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnimalLinkResponse"}}}},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Found"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/results":{"post":{"tags":["Results delivery · CLTCH only"],"summary":"Receive a batch of genetic results","description":"**Scope: CLTCH only.** CLTCH is the result store; Phenome is the sender. (No equivalent\non Phenome.)\n\n**How it works.** Phenome pushes a **batch** of per-animal groups, each carrying a\n`submissionRef`, `species`, and an array of results. Each result has `locus` (Phenome's\nown morph string), `testType` (`morph`|`gender`), a per-morph `status`\n(`order_placed → … → final`), the morph outcome enum, `genderResult`, and the raw\nvendor `rawValue`. For every group CLTCH:\n1. **Routes by `submissionRef`** to the reptile pinned on that submission — a `morphId`\n   alone can't identify a reptile, since morphs are a shared system-level library.\n2. Rejects unknown refs (`UNKNOWN_SUBMISSION`) and non-ball-pythons\n   (`SPECIES_NOT_SUPPORTED`). If the *entire* batch is unsupported it returns **`422`**;\n   otherwise good groups are accepted and bad ones reported per-group.\n3. **Maps** each Phenome morph string to CLTCH's catalog, storing a name snapshot and\n   flagging anything it doesn't recognize (`mapped: false`, e.g. `Rainbow`).\n4. **Upserts idempotently** on `(submissionRef, locus)`.\n\n**Why it's built this way.** Results arrive batched and near-real-time (a 30-morph order\nis one call, not 30) with retry + nightly backfill; the `submissionRef` doubles as the\n**idempotency key** so a real-time push and its nightly re-send never double-write.\nSpecies mismatch is a **terminal** error so Phenome's retry logic doesn't loop on a\npermanent failure. CLTCH owns the morph→id mapping (Phenome only sends strings), and\nunmapped morphs are preserved rather than dropped so nothing is silently lost.","operationId":"receive_results_v1_results_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}]}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResultsBatch"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResultsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}}},"components":{"schemas":{"AnimalLinkRequest":{"properties":{"phenomeAnimalId":{"type":"string","examples":["PH-000123"]},"cltchAnimalId":{"type":"string","examples":["CL-000042"]},"requestingService":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Which side is calling (determines direction)","examples":["cltch"]},"partnerUserId":{"type":"string","description":"Opaque account-link handle for the animal's owner","examples":["5781bb7d8c614472bd2b7dd3465258a9"]},"submissionRef":{"anyOf":[{"type":"string"},{"type":"null"}],"examples":["SUB-1a2b3c4d5e6f"]},"testResults":{"items":{"$ref":"#/components/schemas/ResultItem"},"type":"array"},"knownGenetics":{"items":{"$ref":"#/components/schemas/ListedMorph"},"type":"array"}},"type":"object","required":["phenomeAnimalId","cltchAnimalId","partnerUserId"],"title":"AnimalLinkRequest","description":"Symmetric one-to-one animal link. Either side may call the other. The caller\nsends the data IT is authority for; the receiver returns the data IT owns.\nTrusted/one-way (no reciprocal confirm)."},"AnimalLinkResponse":{"properties":{"status":{"type":"string","examples":["linked"]},"phenomeAnimalId":{"type":"string"},"cltchAnimalId":{"type":"string"},"submissionRef":{"anyOf":[{"type":"string"},{"type":"null"}],"examples":["SUB-1a2b3c4d5e6f"]},"knownGenetics":{"items":{"$ref":"#/components/schemas/ListedMorph"},"type":"array"},"testResults":{"items":{"$ref":"#/components/schemas/ResultItem"},"type":"array"}},"type":"object","required":["status","phenomeAnimalId","cltchAnimalId"],"title":"AnimalLinkResponse"},"AnimalResultOutcome":{"properties":{"phenomeAnimalId":{"type":"string"},"submissionRef":{"anyOf":[{"type":"string"},{"type":"null"}]},"accepted":{"type":"integer","default":0},"rejected":{"type":"boolean","default":false},"reason":{"anyOf":[{"type":"string"},{"type":"null"}]},"results":{"items":{"$ref":"#/components/schemas/ResultDisposition"},"type":"array"},"unmappedMorphs":{"items":{"type":"string"},"type":"array"}},"type":"object","required":["phenomeAnimalId"],"title":"AnimalResultOutcome"},"AnimalResults":{"properties":{"submissionRef":{"type":"string","description":"CLTCH submission reference to route/dedupe results","examples":["SUB-1a2b3c4d5e6f"]},"phenomeAnimalId":{"type":"string"},"cltchAnimalId":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Denormalized partner animal id (readability); routing uses submissionRef"},"species":{"$ref":"#/components/schemas/Species","default":"ball_python"},"results":{"items":{"$ref":"#/components/schemas/ResultItem"},"type":"array"}},"type":"object","required":["submissionRef","phenomeAnimalId","results"],"title":"AnimalResults","description":"Results for one submission (= one shed = one animal). Correlated by\nsubmissionRef (the CLTCH-issued reference), which also serves as the\nidempotency key. species is REQUIRED so CLTCH can terminally reject\nnon-ball-pythons."},"AnimalSearchItem":{"properties":{"id":{"type":"string","examples":["CL-000042"]},"name":{"type":"string","examples":["Sunfire Project F1"]},"title":{"type":"string","examples":["BP-2024-0042"]},"species":{"type":"string","default":"ball_python"},"sex":{"type":"string","default":"Unknown"},"genetics":{"anyOf":[{"items":{"$ref":"#/components/schemas/ListedMorph"},"type":"array"},{"type":"null"}]}},"type":"object","required":["id","name","title"],"title":"AnimalSearchItem"},"AnimalSearchResponse":{"properties":{"items":{"items":{"$ref":"#/components/schemas/AnimalSearchItem"},"type":"array"},"page":{"type":"integer"},"pageSize":{"type":"integer"},"total":{"type":"integer"},"hasMore":{"type":"boolean"}},"type":"object","required":["items","page","pageSize","total","hasMore"],"title":"AnimalSearchResponse"},"ErrorResponse":{"properties":{"error":{"type":"string","examples":["USER_NOT_FOUND"]}},"type":"object","required":["error"],"title":"ErrorResponse"},"GenderResult":{"type":"string","enum":["male","female","unknown"],"title":"GenderResult","description":"Outcome of a gender test (CLTCH's genderResult enum)."},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array"}},"type":"object","title":"HTTPValidationError"},"LinkDisconnect":{"properties":{"requestingUserId":{"type":"string","examples":["phenome-user-12345"]},"targetUserId":{"anyOf":[{"type":"string"},{"type":"null"}],"examples":["asdf123"]}},"type":"object","required":["requestingUserId"],"title":"LinkDisconnect","description":"Either side may disconnect. `targetUserId` is optional. The caller is\nasserting that the named id(s) are no longer linked to ANYONE; the receiver\npurges every link touching them (see the endpoint description)."},"LinkDisconnectResponse":{"properties":{"status":{"type":"string","default":"disconnected"},"removed":{"type":"integer","description":"How many Account Links were purged","default":0}},"type":"object","title":"LinkDisconnectResponse"},"ListedMorph":{"properties":{"morph":{"type":"string","examples":["Pastel"]},"zygosity":{"type":"string","default":"heterozygous"}},"type":"object","required":["morph"],"title":"ListedMorph"},"OAuthAuthorizeRequest":{"properties":{"geneticTestProviderId":{"type":"string","examples":["a7d8fd6f-1515-4d7a-8471-4d3105dc03ba"]},"redirectUri":{"type":"string","examples":["http://localhost:12345/ui/oauth/callback"]},"scope":{"type":"string","default":"","examples":["genetics:share"]},"state":{"type":"string","default":"","examples":["xyz123"]},"codeChallenge":{"type":"string","examples":["E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"]},"codeChallengeMethod":{"type":"string","default":"S256","examples":["S256"]}},"type":"object","required":["geneticTestProviderId","redirectUri","codeChallenge"],"title":"OAuthAuthorizeRequest","description":"Called by CLTCH's OWN frontend (never by the provider directly) once the\nlogged-in CLTCH user has consented on the screen the provider's redirect landed\non. Requires a Cltch user Bearer token — see require_user_token."},"OAuthAuthorizeResponse":{"properties":{"code":{"type":"string","examples":["a1b2c3d4e5f6"]},"state":{"type":"string","examples":["xyz123"]}},"type":"object","required":["code","state"],"title":"OAuthAuthorizeResponse"},"OAuthTokenRequest":{"properties":{"geneticTestProviderId":{"anyOf":[{"type":"string"},{"type":"null"}],"examples":["a7d8fd6f-1515-4d7a-8471-4d3105dc03ba"]},"clientSecret":{"anyOf":[{"type":"string"},{"type":"null"}]},"code":{"type":"string","examples":["a1b2c3d4e5f6"]},"codeVerifier":{"type":"string"},"providerUserId":{"type":"string","description":"The provider's own id for this user","examples":["ext-user-123"]},"redirectUri":{"type":"string"}},"type":"object","required":["code","codeVerifier","providerUserId","redirectUri"],"title":"OAuthTokenRequest","description":"Called by the genetic test provider — NOT a Cltch user.\n\nClient authentication is by `Authorization: Basic base64(clientId:clientSecret)`\n(RFC 6749 §2.3.1) **or** by the same pair in the body. Both are accepted so a client\nwritten against either shape works; the header form is the standard one and what\nCLTCH asked for. Hence both body fields are optional — supply them only when not\nusing the header."},"OAuthTokenResponse":{"properties":{"id":{"type":"string","description":"Opaque link id — the handle the provider must present back as partnerUserId on future calls","examples":["5781bb7d-8c61-4472-bd2b-7dd3465258a9"]},"geneticTestProviderId":{"type":"string","examples":["a7d8fd6f-1515-4d7a-8471-4d3105dc03ba"]},"geneticTestProviderName":{"type":"string","examples":["Phenome"]},"providerUserId":{"type":"string","examples":["ext-user-123"]},"consentToShareGenetic":{"type":"boolean","default":true}},"type":"object","required":["id","geneticTestProviderId","geneticTestProviderName","providerUserId"],"title":"OAuthTokenResponse"},"ResultDisposition":{"properties":{"morphId":{"type":"string","default":""},"locus":{"type":"string","default":""},"disposition":{"type":"string","examples":["stored"]},"reason":{"anyOf":[{"type":"string"},{"type":"null"}]}},"type":"object","required":["disposition"],"title":"ResultDisposition","description":"Per-result outcome so failures/anomalies are reportable at the individual\ntest grain, not just per animal."},"ResultItem":{"properties":{"morphId":{"type":"string","description":"STABLE morph id — CLTCH's mapping/dedup key (empty for gender)","examples":["albino"]},"locus":{"type":"string","description":"Phenome's morph display name (snapshot)","examples":["Albino"]},"testType":{"$ref":"#/components/schemas/TestType","description":"Morph | gender (resultType)","default":"morph"},"status":{"$ref":"#/components/schemas/TestStatus","default":"final"},"result":{"$ref":"#/components/schemas/ResultValue","description":"Morph outcome","default":"no_result"},"genderResult":{"anyOf":[{"$ref":"#/components/schemas/GenderResult"},{"type":"null"}],"description":"Set when test_type == gender"},"rawValue":{"type":"string","description":"Vendor's raw value, retained by CLTCH verbatim","default":""}},"type":"object","required":["morphId","locus"],"title":"ResultItem"},"ResultValue":{"type":"string","enum":["no_result","negative","heterozygous","homozygous"],"title":"ResultValue","description":"Per-morph zygosity result. `no_result` means the test exists / is\nordered but has no readable outcome yet (used for pending statuses)."},"ResultsBatch":{"properties":{"animals":{"items":{"$ref":"#/components/schemas/AnimalResults"},"type":"array"}},"type":"object","required":["animals"],"title":"ResultsBatch","description":"Batched push — one call may carry many submissions' many morph results, so\na 30-morph order is a single request rather than 30."},"ResultsResponse":{"properties":{"acceptedAnimals":{"type":"integer"},"rejectedAnimals":{"type":"integer"},"outcomes":{"items":{"$ref":"#/components/schemas/AnimalResultOutcome"},"type":"array"}},"type":"object","required":["acceptedAnimals","rejectedAnimals","outcomes"],"title":"ResultsResponse"},"Species":{"type":"string","enum":["ball_python","green_python","skink","other"],"title":"Species","description":"CLTCH only supports ball pythons today. `/results` carries species so\nCLTCH can terminally reject anything else (see ResultsRejected)."},"TestStatus":{"type":"string","enum":["order_placed","shed_received","shed_not_readable","sent_to_lab","lab_results_received","retest","final","cancelled"],"title":"TestStatus","description":"Lifecycle of a single per-morph test. Phenome streams these as they\nchange; CLTCH may collapse them to a coarser pending/done on its side.\n\nOrdered roughly by progression. `shed_not_readable` and `retest` are\noff-happy-path states discussed in the meeting. `cancelled` is the other\nterminal state: the Phenome order was cancelled or refunded, so the test\nwill not produce a result. Phenome derives it from the WooCommerce order\nstatus; a morph that already reached `final` keeps `final` instead, because\na refund does not unmake a result that was already reported."},"TestType":{"type":"string","enum":["morph","gender"],"title":"TestType","description":"A test targets either a morph gene or a gender assay. Gender is NOT a morph\non CLTCH's side, so it is typed separately (CLTCH's VendorTestResult.resultType\nis Morph|Gender)."},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array"},"msg":{"type":"string"},"type":{"type":"string"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"}},"securitySchemes":{"bearerApiKey":{"type":"http","scheme":"bearer"}}},"tags":[{"name":"User account unlinking · BOTH services","description":"Severs a user-account link. **Implemented identically on Phenome and CLTCH** — either side may call it. The link itself is established via OAuth (see \"OAuth account linking · CLTCH only (Phenome is the OAuth client)\"); this is the one operation that has no OAuth equivalent and applies regardless of how the link was created."},{"name":"OAuth account linking · CLTCH only (Phenome is the OAuth client)","description":"Establishes a user-account link. **CLTCH only** — CLTCH is the OAuth authorization server (it owns the accounts being consented over); a genetic test provider such as Phenome is the OAuth client. A company-level API key proves the two companies trust each other; this OAuth exchange proves a specific *user* owns a specific account on both sides."},{"name":"Animal linking · BOTH services","description":"Pin one Phenome animal to one CLTCH animal. **Both services expose the receiver endpoint** (Phenome calls it during its order flow; CLTCH calls it during historical import), but the response differs by role — see the operation description."},{"name":"Animal search · CLTCH only","description":"Search a linked user's animals. **CLTCH only** — CLTCH is the animal-registry authority; Phenome consumes this at order time."},{"name":"Results delivery · CLTCH only","description":"Receive genetic result updates from Phenome, routed by the submissionRef that was established when the animal was linked. **CLTCH only** — CLTCH is the result store; Phenome is the sender."},{"name":"Mock utilities · NOT part of the contract","description":"Local mock helpers (health, reset, state dump). **Not part of the integration contract** — present only to make the mock demoable."}],"x-tagGroups":[{"name":"Universal — implemented on BOTH services (identical contract)","tags":["User account unlinking · BOTH services","Animal linking · BOTH services"]},{"name":"CLTCH implements (Phenome calls these)","tags":["OAuth account linking · CLTCH only (Phenome is the OAuth client)","Animal search · CLTCH only","Results delivery · CLTCH only"]},{"name":"Mock-only (not shipped in the contract)","tags":["Mock utilities · NOT part of the contract"]}]}