| name | heater-appb-contracts-openapi |
| description | Use when editing api/contracts/*, when api/openapi.json or web/src/lib/api/generated.ts drift (test_openapi_contract or the CI openapi-ts-sync job failing), when JSON responses 500 or render "nan"/"+nan" badges, or when adding/renaming fields on a response the live frontend already consumes. |
App B Contracts & OpenAPI — the schema is a public promise
api/openapi.json (~225 KB) is the committed contract; web/src/lib/api/generated.ts (~5.9k lines, header "auto-generated by openapi-typescript — do not make direct changes") is its TypeScript projection. Both are build artifacts you REGENERATE, never edit.
The regen loop
# after any contract/router/schema change
python scripts/export_openapi.py # rewrites api/openapi.json from create_app().openapi()
cd web && pnpm gen:api # openapi-typescript ../api/openapi.json -o src/lib/api/generated.ts
Guards: tests/api/test_openapi_contract.py byte-compares (sort_keys) live schema vs committed JSON; CI job openapi-ts-sync (ci.yml:216-249, pnpm 10 — pnpm 9 misreads web/pnpm-workspace.yaml's ignoredBuiltDependencies and refuses install) reruns gen:api and fails on git diff. Merge conflicts in either artifact: take one side, regenerate, commit — never hand-merge.
Contract rules
- Additive only. New fields Optional with defaults; never rename/remove fields a live page reads. The schema name of a moved model must not change (StatItem moved to
api/contracts/common.py but kept its schema name — Team slice 2).
- Shared shapes in
api/contracts/common.py: PlayerRef (id, name, positions, team_abbr, mlb_id, team_id…), StatItem{label,value}. Reuse them; frontends key on these.
- Literals over strings for enums the frontend switches on (post-June-26 hardening); coerce in the service mapper, not the router.
- Pins:
fastapi==0.137.1 + httpx==0.28.1 (requirements.txt:62-67) exist BECAUSE of the snapshot — FastAPI generates the error schemas, so a version bump alone fails test_openapi_contract.py. stripe==15.2.1 is pinned for webhook schema stability.
NaN/inf discipline — the #1 recurring contract bug class
SQLite/pandas hand services NaN and ±inf freely; float('nan') serializes to invalid JSON or leaks the string "nan" into UI.
- Every service has a
_f-style coercer (e.g. streaming_service.py:29-36) mapping non-finite → default. Route EVERY float through it, including late additions.
- Truthiness does not catch NaN:
NaN or "" returns NaN (truthy!), and float(x or 0) does NOT neutralize NaN. Check isinstance(x, str) / math.isfinite explicitly.
Failure stories:
lineup_service.py (~455-460): DCV rows carry reason only when excluded; normal rows had pandas NaN. NaN or "" → str(NaN) == "nan" → red "nan" badge on the live Optimizer. Fix: isinstance(_reason_raw, str) and _reason_raw.strip().
trade_service._f had a latent NaN leak found in the Bugs_June26 sweep — NaN passed the old guard into JSON.
- When
xwoba_delta became NaN-capable (honest-missing instead of fillna(0) → fabricated −0.36), a repo-wide grep missed 2 Streamlit consumers → "+nan" badges. Rule pinned then: when you make a column NaN-capable, grep the WHOLE repo (api/, web/, src/, app.py, pages/) for consumers and use the shared nan_safe_float (src/ui_shared.py) on the Python side.
Sentinels are not data
Pool adp uses COALESCE(adp, 999); emit None, not rank 999 (api/services/player_rankings.py:103-106 — adp.where(adp < 999)). Same for any placeholder: map sentinel → None/"—" at the service boundary. See heater-appb-live-data-honesty.
When NOT to apply
Docs-only or service-internal changes that provably leave the schema unchanged (prove it: run test_openapi_contract.py). Breaking changes a plan explicitly authorizes — still regenerate both artifacts and update frontend consumers in the same PR.