OpenAPI Spec Authoring — Conventions and Pitfalls¶
The Katana client's source of truth is docs/katana-openapi.yaml. Two generator passes
turn the spec into Python:
scripts/regenerate_client.py— emits the attrs models, API methods, andclient.pyunderkatana_public_api_client/.scripts/generate_pydantic_models.py— emits the pydantic mirrors (and the siblingCached<Name>cache tables used by MCP).
A spec edit is therefore not just a doc change — it directly shapes the public client surface and the cache schema. The rules below come from real failures where spec choices broke downstream consumers.
Before editing the spec, audit upstream drift via the workflow in
docs/upstream-specs/README.md:
poe refresh-upstream-spec → poe audit-spec → poe validate-response-examples →
poe validate-examples.
The spec is OpenAPI 3.1 — use 3.1 conventions¶
docs/katana-openapi.yaml declares openapi: 3.1.0. Use 3.1 features rather than 3.0
work-arounds.
$ref siblings are legal in 3.1. Attach property metadata (especially
description) directly alongside $ref, not wrapped in allOf: [{$ref: ...}] (the
3.0 idiom — still legal but unnecessary; the spec has a few legacy cases).
Use allOf only for real composition (combining a $ref with additional properties),
not as a description-attacher.
Property descriptions belong at the use-site, not the schema-definition site¶
When a property references a shared schema via $ref, put the property's description
as a sibling of the $ref so the description describes the role of this field on this
object. The shared schema's own description should describe the type/enum's general
meaning.
The two serve different audiences:
- Schema-definition describes what the type is.
- Use-site describes what the field's value means in context.
Example: ManufacturingOrder.status references ManufacturingOrderStatus and adds
"Current production status of the manufacturing order"; the schema itself just says
"Status of a manufacturing order."
The pydantic generator only emits Annotated[..., Field(description=...)] when the
description is at the use-site, so use-site descriptions are also what surfaces in the
generated client's IDE hovertext and generated docs. A bare $ref drops the
description from generated pydantic — avoid except when the schema's own description
is enough context for every caller (rare).
List responses must use a ListResponse schema with a data array property¶
Katana wraps every GET list endpoint in {"data": [...]}. If the OpenAPI spec defines a
200 response as type: array, the generated parser iterates response.json() directly
— when the API returns the dict wrapper, iteration yields keys (strings) and
Model.from_dict("data") raises:
Always define a proper MyListResponse schema (type: object,
properties.data: {type: array, items: {$ref: ...}}) and reference it from the
operation. The only documented exception is /user_info, which returns a flat object,
not wrapped.
Test fixtures and mocks must also honor this — never put a raw array in a list mock.
Generator/schema edits must commit the regen in the same PR¶
Whenever you edit a generator script (scripts/generate_pydantic_models.py,
scripts/regenerate_client.py) or the OpenAPI spec (docs/katana-openapi.yaml),
run the regen, run uv run poe check (or at minimum agent-check + uv run poe test),
and commit the regenerated output in the same PR. The input and its output stay
locked together at every commit so the cause-and-effect chain is reviewable.
- Pushing a generator/spec change without its regen leaves CI green-but-stale until the next time someone runs the generator.
- Pushing regen output without the input change drifts in the other direction.
Note the generated-file impact in the PR description (e.g., "byte-identical except X" or list affected files).
Breaking-change marker¶
When the regen drops a previously-public class (e.g., a StrEnum deduped into a
sibling) or narrows a field's type, the commit must use the breaking-change marker
(feat(client)!: / fix(client)!:) with a BREAKING CHANGE: footer naming the
affected symbol — see
.github/agents/guides/shared/COMMIT_STANDARDS.md
"Schema and Generator Changes" for the full rule.
Real names and emails from live API responses must never enter the repo¶
When testing against the live Katana API and incorporating response data into the spec,
examples, or test fixtures, replace real names/emails with generic placeholders
(Jane Doe, jane.doe@example.com, etc.). Privacy concern — real user data from
production accounts should not be committed.
POST create endpoints return 200, not 201¶
Katana's convention across virtually every create endpoint is to return HTTP 200 on
success — not the REST-orthodox 201. Authoring or copy-pasting "201": for a new
post: block is a recurring footgun: the generated _parse_response only handles the
documented status code, so when Katana actually returns 200 the parser falls through to
"unknown status", leaves response.parsed = None, and unwrap_as raises
UnexpectedResponse — even though the mutation landed server-side. The bug looks like
a failure to the caller and invites a destructive retry.
Verified live (make_test_client() probe, 2026-05-27 and 2026-05-28):
POST /sales_order_fulfillments→ 200POST /stock_transfers→ 200POST /sales_return_rows→ 200POST /inventory_reorder_points→ 200POST /outsourced_purchase_order_recipe_rows→ 200
Pinned by
tests/test_openapi_specification.py::test_create_endpoint_success_status_codes and
exercised end-to-end by tests/test_create_endpoint_regression.py.
The full sweep is complete — every POST create endpoint in the spec now declares 200.
A baseline survey at the time of the sweep found these five outliers and no others.
If you genuinely encounter a Katana create endpoint that returns 201 (none confirmed to
date), accept both by declaring "200" and "201" in the same responses: map
pointing at the same schema — this future-proofs against Katana flipping the status code
and is what the generator handles cleanly.
Two custom-fields surfaces coexist — never unify them¶
Katana exposes two unrelated custom-fields mechanisms. They look similar but have different wire shapes, different configuration endpoints, and different key semantics. Keep them separate in the spec; do not "DRY" one into the other.
| Legacy (items) | New (sales orders) | |
|---|---|---|
| Entities | Variant / Product / Material / Service | SalesOrder / SalesOrderRow |
| Wire shape | [{field_name, field_value}] array |
{<uuid>: value} dict |
| Configured via | /custom_fields_collections |
/custom_field_definitions |
| Key | configured field name | definition id (UUID) |
| Canonical schema | CustomFieldValue |
CustomFieldDefinition |
The array surface is modelled by CustomFieldValue and the dict surface by
CustomFieldDefinition + the CustomFieldOptions choice schemas. The dict values are
typed by the definition's field_type (string / number / boolean / YYYY-MM-DD / URL
string / integer choice id for singleSelect). entity_type is intentionally
narrowed to the two live values — add a new entity type only once the live API accepts
it. Katana has not migrated items/variants to the dict shape; if/when it does, that
is a deliberate, separately-tracked spec change, not a silent unification here.
Sales-order custom-field search paths use snake_case custom_fields.<uuid> in
where/order (matching the request/response body), per Katana's live
/sales_orders/search schema. Pre-GA partner docs showed a camelCase
customFields.<id> asymmetry, but the shipped API uses snake_case — verified live
against the test tenant (make_test_client() probe, 2026-06-02): custom_fields.<uuid>
passes where validation, while customFields.<uuid> is rejected with the same
additionalProperties / "unexpected property" 422 as any unknown field. Do not
reintroduce the camelCase form.
Fix bugs at the client/generator layer when the root cause lives there¶
The Katana client (katana_public_api_client) is a published, standalone package.
Third-party Python users hit the same bugs we hit in MCP. When a bug surfaces in
katana_mcp_server/.../typed_cache/sync.py, in a foundation tool, or in a helper but
originates in generated client code (attrs, pydantic, from_attrs, Cached* schemas),
apply the fix to the generator or spec — not the consumer.
Test: "would a standalone client user hit this bug?" If yes, fix it in the client.
Examples:
Pydantic*.from_attrsraising on{}from Katana → fixfrom_attrscodegen, not_attrs_*_to_cached.Column(JSON)failing to serialize a pydantic instance → fixinject_json_columnsinscripts/generate_pydantic_models.py, notsync.py.- Missing enum value → patch spec + regenerate, not enum-tolerant deserialization downstream.