{"openapi":"3.1.0","info":{"title":"Phenome Integration API (mock)","summary":"Mock of the Phenome side of the Phenome ⇄ CLTCH integration.","description":"Mock of the **Phenome** 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 **Phenome** 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/token":{"post":{"tags":["OAuth account linking · CLTCH only (Phenome is the OAuth client)"],"summary":"Mint a machine access token (client credentials)","description":"**Scope: Phenome only.** The counterpart to CLTCH's OAuth server: CLTCH authorizes\n*users* linking to Phenome, Phenome authorizes *CLTCH itself* calling back in.\n\n**How it works.** Standard OAuth 2.0 Client Credentials (RFC 6749 §4.4). POST\n`grant_type=client_credentials` as `application/x-www-form-urlencoded`, authenticating\nwith HTTP Basic using the client id and secret Phenome issued out-of-band. Returns a\nshort-lived opaque bearer token. There is no refresh token — re-minting is a single\ncall, so one would only add state to get wrong.\n\n**What the token does and does not grant.** It proves *which company* is calling. It\ndoes **not** identify a user, so on user-context routes\n(`GET /v1/animals/{phenomeAnimalId}/results`, `POST /v1/animals/link`) it must be\naccompanied by `partnerUserId` — the opaque account-link handle from the OAuth linking\nflow — and Phenome checks the animal belongs to that link's owner. Token alone reaches\nnothing user-specific.\n\n**Why it's built this way.** Background traffic (result pushes, the morph catalog) is\ncompany-level and keeps the plain API key. Traffic made *because a person clicked\nsomething* needs to say which person, and a company credential cannot. Splitting on\ncaller context — rather than on direction — is what lets a single leaked machine\ncredential reach no user's data at all.","operationId":"issue_token_v1_oauth_token_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}]}}],"requestBody":{"required":true,"content":{"application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/Body_issue_token_v1_oauth_token_post"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{},"example":{"access_token":"0f1e2d…","token_type":"Bearer","expires_in":3600}}}},"400":{"content":{"application/json":{"example":{"error":"unsupported_grant_type"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"example":{"error":"invalid_client"}}},"description":"Unauthorized"},"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/animals/{phenome_animal_id}/results":{"get":{"tags":["Historical results · PHENOME only"],"summary":"Read all results Phenome holds for an animal","description":"**Scope: Phenome only.** Phenome is the genetics authority. (No equivalent on CLTCH.)\n**PROPOSED addition** — not in the draft spec.\n\n**How it works.** Returns every genetic result Phenome holds for the given Phenome\nanimal id: `locus`, `testType` (morph|gender), per-morph `status`, morph outcome,\n`genderResult`, and the retained `rawValue`.\n\n**Why it's built this way.** It's an explicit backfill/reconciliation endpoint so CLTCH\ncan pull an animal's pre-existing results after linking — complementing the\n`existingResults` returned inline by `POST /v1/animals/link`. Search of Phenome animals\nby breeder name/id is **deferred** (per the July 6 meeting): for now the breeder pastes\nthe Phenome id to link, and name-search is a later enhancement. Reliable matching for\nlegacy animals that predate the integration is still an open item (see the BRD red\nflag).","operationId":"historical_results_v1_animals__phenome_animal_id__results_get","parameters":[{"name":"phenome_animal_id","in":"path","required":true,"schema":{"type":"string"}},{"name":"partnerUserId","in":"query","required":true,"schema":{"type":"string","description":"Opaque account-link handle for the animal's owner"},"description":"Opaque account-link handle for the animal's owner"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}]}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ResultItem"},"title":"Response Historical Results V1 Animals  Phenome Animal Id  Results Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/morphs":{"get":{"tags":["Morph catalog · PHENOME only"],"summary":"List Phenome's morph vocabulary (stable id + display name)","description":"**Scope: Phenome only.** (No equivalent on CLTCH.)\n\n**How it works.** Returns every morph Phenome recognizes as `{morphId, name, aliases,\nnewOnPhenome}`. `morphId` is stable; `name` is the current display label and may change.\n\n**Why it's built this way.** Morph names change over time (e.g. \"special\" →\n\"Baker special\"). If CLTCH keyed its mapping on the display string it would silently\nbreak on a rename. Publishing a **stable id** — and letting CLTCH pull the current list\nhere — means renames are just a `name` update against an unchanged `morphId`, and newly\nadded morphs (`newOnPhenome: true`, e.g. `Rainbow`) are discoverable rather than\nsurfacing only as unmapped results.","operationId":"list_morphs_v1_morphs_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}]}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/MorphListItem"},"title":"Response List Morphs V1 Morphs Get"}}}},"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"},"Body_issue_token_v1_oauth_token_post":{"properties":{"grant_type":{"type":"string","description":"Must be `client_credentials`"}},"type":"object","required":["grant_type"],"title":"Body_issue_token_v1_oauth_token_post"},"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"},"MorphListItem":{"properties":{"morphId":{"type":"string","description":"Stable id — CLTCH maps on this","examples":["clown"]},"name":{"type":"string","description":"Current display name (may change)","examples":["Clown"]},"aliases":{"items":{"type":"string"},"type":"array"},"newOnPhenome":{"type":"boolean","default":false}},"type":"object","required":["morphId","name"],"title":"MorphListItem"},"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)."},"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":"Historical results · PHENOME only","description":"Read pre-existing results for backfill. **Phenome only** — Phenome is the genetics authority."},{"name":"Morph catalog · PHENOME only","description":"Publish Phenome's morph vocabulary (stable id + display name) so CLTCH can build/refresh its mapping. **Phenome only.**"},{"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)"]},{"name":"Phenome implements (CLTCH calls these)","tags":["Historical results · PHENOME only","Morph catalog · PHENOME only"]},{"name":"Mock-only (not shipped in the contract)","tags":["Mock utilities · NOT part of the contract"]}]}