SDKs
Generated TypeScript and Python clients.
Source: docs/initiatives/memory-api/SDKS.md — this page is rendered from it, not copied. Generated from the contract, never written by hand.
Date: 2026-07-29 · Backlog item S3 · Script: scripts/generate_sdks.sh
Two SDKs — TypeScript and Python — are functions of openapi.yaml. Nobody writes one. Nothing generated is committed. One command produces both, and the command fails if the contract is invalid or either SDK does not build.
Run it
cd ~/Documents/brainComputer/brain-platform
scripts/generate_sdks.shReal output, re-run 2026-07-29 from a clean generated tree:
── toolchain
── validating docs/initiatives/memory-api/openapi.yaml
docs/initiatives/memory-api/openapi.yaml: OK
every $ref resolves
contract version 0.1.0
── python
Generating dist/sdk/python/brain-memory-api-client
python sdk imports: the two calls that are the whole product are present
── typescript
@hey-api/openapi-ts v0.99.0
[Job 1] ✓ dist/sdk/typescript/src · 4 files · 92ms
typescript sdk typechecks under --strict
── postcondition
all 15 operations present in both SDKs
generated v0.1.0 from docs/initiatives/memory-api/openapi.yaml
dist/sdk/python/brain-memory-api-client 88 files
dist/sdk/typescript 16 filesThe dated 2026-07-27 timing snapshot was 10.5 s cold with warm pip/pnpm caches and 4.1 s warm; the 2026-07-29 run above re-verifies contract shape, imports, typechecking, operation presence, and file counts rather than claiming a new benchmark. The TypeScript side needs pnpm or npx on PATH — this machine has only pnpm, so the npx branch is written but unproven and CI is where it gets its first real run. Python needs nothing but an interpreter ≥3.11: /usr/bin/python3 on macOS is 3.9 and silently resolves the generator to a years-old release instead of failing, so the script picks the interpreter rather than inheriting it.
What you get
dist/sdk/ gitignored, disposable, NEVER hand-edited
.toolchain/py/ the pinned generators, in their own venv
python/brain-memory-api-client/ 88 Python files · 8,277 lines · installable package
typescript/ 16 TypeScript files · 3,291 lines · package.json + tsconfig + src/1,202 lines of contract → 11,568 lines of client, in two languages. That ratio is the whole argument: it is the volume of code nobody has to review, keep in sync, or be wrong in.
All 15 operations are present in both, named by operationId — createBrain, getBrain, forgetBrain, getProfile, getCensus, listTraces, getTrace, rememberRecord, getRecord, forgetRecord, listMemories, getMemory, acceptObject, getObject, and createContext.
The TypeScript package has zero runtime dependencies — the fetch client is emitted inline, so there is no third-party package between a caller and the contract.
The Python package builds, installs and imports as part of the run. A generator that emits a tree of files that does not import is a generator that failed quietly; this one is checked.
The knob budget lands in the type system
This is the property that makes generation more than convenience. ContextRequest in the generated Python, unedited:
query: str
purpose: str | Unset = UNSET
as_of: datetime.datetime | Unset = UNSET
budget: ContextRequestBudget | Unset = UNSET
include: list[ContextRequestIncludeItem] | Unset = UNSET
schema: ContextRequestSchema | Unset = UNSET
filter_: ContextRequestFilter | Unset = UNSET
mode: ContextRequestMode | Unset = ContextRequestMode.FASTSeven read knobs, and mode defaults to fast because the contract says so. An eighth knob appears here, and in types.gen.ts, in the same commit that adds it — visible to every reviewer, in two languages, before check_api_simplicity.py even runs.
The gotcha this built: all three tools fail silently
Found while checking that the validator was not a no-op, 2026-07-27. Point createBrain's requestBody at #/components/schemas/NoSuchSchema and:
Result on a dangling $ref | Exit code | |
|---|---|---|
openapi-spec-validator | openapi.yaml: OK | 0 |
openapi-python-client | WARNING … Endpoint will not be generated — the endpoint is dropped | 0 |
@hey-api/openapi-ts | Skipping unresolvable $ref — the operation is emitted with a hole | 0 |
Three tools, three green lights, one SDK missing an operation the contract promises. A naive "validate then generate" script reports success. openapi-spec-validator checks the document against the OpenAPI JSON Schema; it does not resolve local pointers, and neither generator treats an unresolvable one as fatal.
So the script does not trust any of them:
- Every
$refis resolved before anything is generated, and an unresolvable one is fatal with the exact JSON path. External (http://…) refs are refused outright — a contract that is only complete over the network is not reproducible. --fail-on-warningonopenapi-python-client, which is off by default. A partial SDK is not a green build.- A postcondition: every
operationIdin the contract must exist as a module in the Python tree and as anexport constinsdk.gen.ts. This is the one check that catches the TypeScript case, because there the file count does not change.
All three were verified to fire, not assumed to (see Verification).
The general form is one the repo already knows: a check that cannot express failure is not a check. "It validated" and "it generated" are both satisfiable by a tool that quietly did less than you asked.
Why generated and not hand-written
A hand-written SDK is a second implementation of the contract, and its failure mode is silent. It keeps compiling after the server changes, because it encodes its author's belief about the API rather than the API. The divergence surfaces at a customer, months later, in the one path nobody wrote a test for. This is the same class of error the repo already spends gates on: a generated FastAPI schema can only ever agree with itself, which is why check_api_conformance.sh fuzzes against the checked-in openapi.yaml. An SDK written by hand reintroduces exactly the problem that check exists to prevent, one layer further out — and further out is worse, because it is the layer the customer touches.
Four consequences that are worth more than the typing they save:
- The contract cannot lie about the client. The only way for the SDK to be wrong is for the contract to be wrong — and the contract has two gates on it already.
- An ungeneratable spec is a spec bug, caught at build time. Validation and
$refresolution run before generation and the script exits non-zero. A contract that no generator can read is a contract no customer can implement either; better to learn that here. - Surface is visible. See above. Quiet API growth is the thing the simplicity law exists to stop, and generation makes the growth show up as a diff in a typed file.
- Marginal cost per language is ~zero. Go, Rust, Kotlin are one more line in the script, not one more artifact to keep honest.
The rule that follows: hand-written code never lives inside dist/sdk/. If an ergonomic wrapper is ever wanted (brain.remember(...) over rememberRecord(client, ...)), it is a separate, committed package that depends on the generated one. The generated tree stays a pure function of the contract, or it is not one.
The generators, and one we declined
| Tool | Pin | Why | |
|---|---|---|---|
| Python | openapi-python-client | 0.29.0 | Reads OpenAPI 3.1 natively. httpx + attrs, typed, py.typed, sync and async. |
| TypeScript | @hey-api/openapi-ts | 0.99.0 | Reads 3.1 natively, emits a self-contained fetch client, no runtime deps. |
| — | ruff | 0.15.0 | openapi-python-client shells out to it to format its output. Pinned into the toolchain so the format step cannot silently skip. |
| — | openapi-spec-validator | 0.7.2 | The fail-fast. Dev-only, toolchain-only. |
| — | typescript | 5.9.3 | Peer of the TS generator, and the --strict typecheck of its output. |
openapi-generator was evaluated and declined, for two reasons — the first checked here, the second on the record and not verifiable here:
- It is a JVM tool and this machine has no JVM — checked:
java -versionfails,/usr/libexec/java_homefinds nothing, no JDK under/Library/Java/JavaVirtualMachines. It could not be run, so a build step using it could not be verified — and an unverified build step is how a "generated" SDK quietly becomes a hand-written one, the first time somebody cannot run the script. - Its OpenAPI 3.1 support is partial and works by down-conversion to 3.0. This one is the project's own documented position, not something measured here — with no JVM there was no way to test it. If it is out of date, the check is cheap: install a JDK and diff the generated types against the shapes this contract leans on —
type: [string, 'null'](valid_to,text,abstained,next_cursor,if_version),const(theobjectdiscriminator on every resource), and 3.1examples. A generator that has to lower the contract to read it produces a client for a different contract.
Both chosen tools are open source and read 3.1 as written. If a JVM ever lands on the build host and a language is wanted that only openapi-generator covers, add it as a third generator; do not replace these.
The pins are load-bearing: a generator upgrade is an SDK change. Bumping any version above is its own commit, reviewed against the regenerated diff.
Release policy
- The SDK version is the contract version.
package.jsonand the Pythonpyproject.tomlboth takeinfo.versionfromopenapi.yaml. There is no field anywhere to type a different number into. An SDK cannot claim a version the contract does not have. - Nothing generated is committed.
dist/sdk/is gitignored twice over (.gitignore:dist/anddist/sdk/). Thousands of generated lines in a pull request is how a real ten-line diff gets missed, and a generated file in the tree is a file somebody eventually edits. - A release is a contract release. Bump
info.version→check_api_simplicity.pyGREEN →check_api_conformance.shGREEN →generate_sdks.shGREEN → publish → tagsdk-v<version>. The gates come first; an SDK is downstream of a contract that passed. - A change that fails the simplicity law never ships, in any language. The generator will happily emit an eighth knob. The gate is what refuses it.
- Regenerate on every contract change, not on demand. The correct time to discover the spec no longer generates is the commit that broke it.
Not yet automated, stated plainly: the publish step itself. The npm scope @brain is not claimed and the PyPI name is not registered, so generate_sdks.sh stops at a built, typechecked, importable package in dist/sdk/ and does not push anywhere. Wiring npm publish / twine upload into CI is a follow-up to S3, not part of it.
Known warts, not patched
Two name collisions the contract's own nouns cause. Both are real; neither was silently worked around, because working around them means editing the contract to suit a generator:
- TypeScript exports
Record,ObjectandErroras types — all three shadow globals. A consumer writingimport type { Record } from '@brain/memory-api'loses TypeScript'sRecord<K,V>utility type in that file. Namespace import (import type * as Brain from …) avoids it. - Python renames the
filterknob tofilter_(ContextRequest.filter_), the generator's standard escape for a builtin.
Neither is worth renaming a resource over. Both belong in the quickstart (S10) so a stranger meets them in the docs rather than in a compiler error.
Verification, 2026-07-27
The contract validates as OpenAPI 3.1 — no errors, nothing patched to make it pass. generate_sdks.sh runs this before it generates anything; to run it alone (after one generate_sdks.sh has built the toolchain):
$ dist/sdk/.toolchain/py/bin/python -m openapi_spec_validator docs/initiatives/memory-api/openapi.yaml
docs/initiatives/memory-api/openapi.yaml: OKThat "OK" was itself checked. Against a copy with operationId set to an integer and responses to a string, the same validator exits 1 with Validation Error: 12345 is not of type 'string' and the failing instance path — so the green above is a result, not a default.
And each of the three added guards was made to fire, by running the shipped code against a deliberately broken copy of the contract (#/components/schemas/BrainCreate → #/components/schemas/NoSuchSchema) and against an amputated pair of SDKs:
$ <shipped $ref check> broken.yaml
UNRESOLVABLE $ref #/components/schemas/NoSuchSchema at $.paths./brains.post.requestBody.content.application/json.schema
exit 1
$ openapi-python-client generate --path broken.yaml --fail-on-warning …
WARNING parsing POST /brains within brains. Endpoint will not be generated.
exit 1 # exit 0 without the flag
$ <shipped postcondition> openapi.yaml <sdk tree with create_brain.py deleted and createContext renamed>
MISSING OPERATION python: createBrain (create_brain.py)
MISSING OPERATION typescript: createContext
exit 1The simplicity law is unaffected by this work — no resource, verb, or knob was added, because an SDK is not surface:
$ .venv/bin/python scripts/check_api_simplicity.py
resources 5/5 Brain, Context, Memory, Object, Record
verbs 6/6 accept, create, forget, get, list, remember
read knobs 7/7
write knobs 3/3
GREEN — the simplicity law holds.The generators live in dist/sdk/.toolchain/py, not in the project .venv. openapi-spec-validator pins referencing<0.37.0 and installing it into .venv downgrades a package the conformance gate resolves through. An SDK build must not be able to perturb a gate, so the toolchain is isolated and the project venv is left exactly as found (pip check: no broken requirements).
# SDKs — generated from the contract, never written by hand
**Date:** 2026-07-29 · Backlog item **S3** · Script: [`scripts/generate_sdks.sh`](../../../scripts/generate_sdks.sh)
Two SDKs — TypeScript and Python — are **functions of** [`openapi.yaml`](./openapi.yaml). Nobody
writes one. Nothing generated is committed. One command produces both, and the command fails if the
contract is invalid or either SDK does not build.
---
## Run it
```bash
cd your checkout/brain-platform
scripts/generate_sdks.sh
```
Real output, re-run 2026-07-29 from a clean generated tree:
```
── toolchain
── validating docs/initiatives/memory-api/openapi.yaml
docs/initiatives/memory-api/openapi.yaml: OK
every $ref resolves
contract version 0.1.0
── python
Generating dist/sdk/python/brain-memory-api-client
python sdk imports: the two calls that are the whole product are present
── typescript
@hey-api/openapi-ts v0.99.0
[Job 1] ✓ dist/sdk/typescript/src · 4 files · 92ms
typescript sdk typechecks under --strict
── postcondition
all 15 operations present in both SDKs
generated v0.1.0 from docs/initiatives/memory-api/openapi.yaml
dist/sdk/python/brain-memory-api-client 88 files
dist/sdk/typescript 16 files
```
The dated 2026-07-27 timing snapshot was `10.5 s` cold with warm pip/pnpm caches and `4.1 s` warm;
the 2026-07-29 run above re-verifies contract shape, imports, typechecking, operation presence, and
file counts rather than claiming a new benchmark. The TypeScript side needs `pnpm` **or** `npx` on
`PATH` — this machine has only `pnpm`, so the `npx` branch is written but unproven and CI is where
it gets its first real run. Python needs nothing but an interpreter ≥3.11: `/usr/bin/python3` on
macOS is 3.9 and silently resolves the generator to a years-old release instead of failing, so the
script picks the interpreter rather than inheriting it.
---
## What you get
```
dist/sdk/ gitignored, disposable, NEVER hand-edited
.toolchain/py/ the pinned generators, in their own venv
python/brain-memory-api-client/ 88 Python files · 8,277 lines · installable package
typescript/ 16 TypeScript files · 3,291 lines · package.json + tsconfig + src/
```
**1,202 lines of contract → 11,568 lines of client, in two languages.** That ratio is
the whole argument: it is the volume of code nobody has to review, keep in sync, or be wrong in.
All **15 operations** are present in both, named by `operationId` — `createBrain`, `getBrain`,
`forgetBrain`, `getProfile`, `getCensus`, `listTraces`, `getTrace`, `rememberRecord`, `getRecord`,
`forgetRecord`, `listMemories`, `getMemory`, `acceptObject`, `getObject`, and `createContext`.
The TypeScript package has **zero runtime dependencies** — the fetch client is emitted inline, so
there is no third-party package between a caller and the contract.
The Python package **builds, installs and imports** as part of the run. A generator that emits a
tree of files that does not import is a generator that failed quietly; this one is checked.
### The knob budget lands in the type system
This is the property that makes generation more than convenience. `ContextRequest` in the generated
Python, unedited:
```python
query: str
purpose: str | Unset = UNSET
as_of: datetime.datetime | Unset = UNSET
budget: ContextRequestBudget | Unset = UNSET
include: list[ContextRequestIncludeItem] | Unset = UNSET
schema: ContextRequestSchema | Unset = UNSET
filter_: ContextRequestFilter | Unset = UNSET
mode: ContextRequestMode | Unset = ContextRequestMode.FAST
```
Seven read knobs, and `mode` defaults to `fast` because the contract says so. An eighth knob appears
here, and in `types.gen.ts`, in the same commit that adds it — visible to every reviewer, in two
languages, before `check_api_simplicity.py` even runs.
---
## The gotcha this built: **all three tools fail silently**
Found while checking that the validator was not a no-op, 2026-07-27. Point `createBrain`'s
`requestBody` at `#/components/schemas/NoSuchSchema` and:
| | Result on a dangling `$ref` | Exit code |
|---|---|---|
| `openapi-spec-validator` | `openapi.yaml: OK` | **0** |
| `openapi-python-client` | `WARNING … Endpoint will not be generated` — the endpoint is *dropped* | **0** |
| `@hey-api/openapi-ts` | `Skipping unresolvable $ref` — the operation is emitted **with a hole** | **0** |
Three tools, three green lights, one SDK missing an operation the contract promises. A naive
"validate then generate" script reports success. `openapi-spec-validator` checks the document
against the OpenAPI JSON Schema; it does **not** resolve local pointers, and neither generator
treats an unresolvable one as fatal.
So the script does not trust any of them:
1. **Every `$ref` is resolved** before anything is generated, and an unresolvable one is fatal with
the exact JSON path. External (`http://…`) refs are refused outright — a contract that is only
complete over the network is not reproducible.
2. **`--fail-on-warning`** on `openapi-python-client`, which is off by default. A partial SDK is not
a green build.
3. **A postcondition**: every `operationId` in the contract must exist as a module in the Python
tree *and* as an `export const` in `sdk.gen.ts`. This is the one check that catches the
TypeScript case, because there the file count does not change.
All three were verified to fire, not assumed to (see Verification).
The general form is one the repo already knows: **a check that cannot express failure is not a
check.** "It validated" and "it generated" are both satisfiable by a tool that quietly did less
than you asked.
---
## Why generated and not hand-written
**A hand-written SDK is a second implementation of the contract, and its failure mode is silent.**
It keeps compiling after the server changes, because it encodes its author's belief about the API
rather than the API. The divergence surfaces at a customer, months later, in the one path nobody
wrote a test for. This is the same class of error the repo already spends gates on: a generated
FastAPI schema can only ever agree with itself, which is why
[`check_api_conformance.sh`](../../../scripts/check_api_conformance.sh) fuzzes against the
**checked-in** `openapi.yaml`. An SDK written by hand reintroduces exactly the problem that check
exists to prevent, one layer further out — and further out is worse, because it is the layer the
customer touches.
Four consequences that are worth more than the typing they save:
1. **The contract cannot lie about the client.** The only way for the SDK to be wrong is for the
contract to be wrong — and the contract has two gates on it already.
2. **An ungeneratable spec is a spec bug, caught at build time.** Validation and `$ref` resolution
run before generation and the script exits non-zero. A contract that no generator can read is a
contract no customer can implement either; better to learn that here.
3. **Surface is visible.** See above. Quiet API growth is the thing the simplicity law exists to
stop, and generation makes the growth show up as a diff in a typed file.
4. **Marginal cost per language is ~zero.** Go, Rust, Kotlin are one more line in the script, not
one more artifact to keep honest.
The rule that follows: **hand-written code never lives inside `dist/sdk/`.** If an ergonomic wrapper
is ever wanted (`brain.remember(...)` over `rememberRecord(client, ...)`), it is a separate,
committed package that *depends on* the generated one. The generated tree stays a pure function of
the contract, or it is not one.
---
## The generators, and one we declined
| | Tool | Pin | Why |
|---|---|---|---|
| Python | [`openapi-python-client`](https://github.com/openapi-generators/openapi-python-client) | `0.29.0` | Reads OpenAPI 3.1 natively. `httpx` + `attrs`, typed, `py.typed`, sync and async. |
| TypeScript | [`@hey-api/openapi-ts`](https://github.com/hey-api/openapi-ts) | `0.99.0` | Reads 3.1 natively, emits a self-contained fetch client, no runtime deps. |
| — | `ruff` | `0.15.0` | `openapi-python-client` shells out to it to format its output. Pinned into the toolchain so the format step cannot silently skip. |
| — | `openapi-spec-validator` | `0.7.2` | The fail-fast. Dev-only, toolchain-only. |
| — | `typescript` | `5.9.3` | Peer of the TS generator, and the `--strict` typecheck of its output. |
**`openapi-generator` was evaluated and declined**, for two reasons — the first checked here, the
second on the record and *not* verifiable here:
- **It is a JVM tool and this machine has no JVM** — checked: `java -version` fails,
`/usr/libexec/java_home` finds nothing, no JDK under `/Library/Java/JavaVirtualMachines`. It could
not be *run*, so a build step using it could not be *verified* — and an unverified build step is
how a "generated" SDK quietly becomes a hand-written one, the first time somebody cannot run the
script.
- **Its OpenAPI 3.1 support is partial and works by down-conversion to 3.0.** This one is the
project's own documented position, not something measured here — with no JVM there was no way to
test it. If it is out of date, the check is cheap: install a JDK and diff the generated types
against the shapes this contract leans on — `type: [string, 'null']` (`valid_to`, `text`,
`abstained`, `next_cursor`, `if_version`), `const` (the `object` discriminator on every resource),
and 3.1 `examples`. A generator that has to lower the contract to read it produces a client for a
different contract.
Both chosen tools are open source and read 3.1 as written. If a JVM ever lands on the build host and
a language is wanted that only `openapi-generator` covers, add it as a **third** generator; do not
replace these.
The pins are load-bearing: **a generator upgrade is an SDK change.** Bumping any version above is
its own commit, reviewed against the regenerated diff.
---
## Release policy
1. **The SDK version *is* the contract version.** `package.json` and the Python `pyproject.toml`
both take `info.version` from `openapi.yaml`. There is no field anywhere to type a different
number into. An SDK cannot claim a version the contract does not have.
2. **Nothing generated is committed.** `dist/sdk/` is gitignored twice over
([`.gitignore`](../../../.gitignore): `dist/` and `dist/sdk/`). Thousands of generated lines in a
pull request is how a real ten-line diff gets missed, and a generated file in the tree is a file
somebody eventually edits.
3. **A release is a contract release.** Bump `info.version` → `check_api_simplicity.py` GREEN →
`check_api_conformance.sh` GREEN → `generate_sdks.sh` GREEN → publish → tag `sdk-v<version>`.
The gates come first; an SDK is downstream of a contract that passed.
4. **A change that fails the simplicity law never ships**, in any language. The generator will
happily emit an eighth knob. The gate is what refuses it.
5. **Regenerate on every contract change, not on demand.** The correct time to discover the spec no
longer generates is the commit that broke it.
**Not yet automated, stated plainly:** the publish step itself. The npm scope `@brain` is not
claimed and the PyPI name is not registered, so `generate_sdks.sh` stops at a built, typechecked,
importable package in `dist/sdk/` and does not push anywhere. Wiring `npm publish` / `twine upload`
into CI is a follow-up to S3, not part of it.
---
## Known warts, not patched
Two name collisions the contract's own nouns cause. Both are real; neither was silently worked
around, because working around them means editing the contract to suit a generator:
- **TypeScript** exports `Record`, `Object` and `Error` as types — all three shadow globals. A
consumer writing `import type { Record } from '@brain/memory-api'` loses TypeScript's `Record<K,V>`
utility type *in that file*. Namespace import (`import type * as Brain from …`) avoids it.
- **Python** renames the `filter` knob to `filter_` (`ContextRequest.filter_`), the generator's
standard escape for a builtin.
Neither is worth renaming a resource over. Both belong in the quickstart (S10) so a stranger meets
them in the docs rather than in a compiler error.
---
## Verification, 2026-07-27
The contract validates as OpenAPI 3.1 — **no errors, nothing patched to make it pass.**
`generate_sdks.sh` runs this before it generates anything; to run it alone (after one
`generate_sdks.sh` has built the toolchain):
```
$ dist/sdk/.toolchain/py/bin/python -m openapi_spec_validator docs/initiatives/memory-api/openapi.yaml
docs/initiatives/memory-api/openapi.yaml: OK
```
**That "OK" was itself checked.** Against a copy with `operationId` set to an integer and
`responses` to a string, the same validator exits 1 with
`Validation Error: 12345 is not of type 'string'` and the failing instance path — so the green above
is a result, not a default.
**And each of the three added guards was made to fire**, by running the shipped code against a
deliberately broken copy of the contract (`#/components/schemas/BrainCreate` →
`#/components/schemas/NoSuchSchema`) and against an amputated pair of SDKs:
```
$ <shipped $ref check> broken.yaml
UNRESOLVABLE $ref #/components/schemas/NoSuchSchema at $.paths./brains.post.requestBody.content.application/json.schema
exit 1
$ openapi-python-client generate --path broken.yaml --fail-on-warning …
WARNING parsing POST /brains within brains. Endpoint will not be generated.
exit 1 # exit 0 without the flag
$ <shipped postcondition> openapi.yaml <sdk tree with create_brain.py deleted and createContext renamed>
MISSING OPERATION python: createBrain (create_brain.py)
MISSING OPERATION typescript: createContext
exit 1
```
The simplicity law is unaffected by this work — no resource, verb, or knob was added, because an
SDK is not surface:
```
$ .venv/bin/python scripts/check_api_simplicity.py
resources 5/5 Brain, Context, Memory, Object, Record
verbs 6/6 accept, create, forget, get, list, remember
read knobs 7/7
write knobs 3/3
GREEN — the simplicity law holds.
```
The generators live in `dist/sdk/.toolchain/py`, **not** in the project `.venv`.
`openapi-spec-validator` pins `referencing<0.37.0` and installing it into `.venv` downgrades a
package the conformance gate resolves through. An SDK build must not be able to perturb a gate, so
the toolchain is isolated and the project venv is left exactly as found (`pip check`: no broken
requirements).