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.
Custom-field request formats depend on the endpoint and account¶
The legacy [{field_name, field_value}] array uses names configured through
/custom_fields_collections. The newer object form uses definition UUIDs from
/custom_field_definitions. Preserve these different key semantics.
Order, row, customer, supplier, BOM, and manufacturing request DTOs accept a free-form
nullable object. Variant create/update and service update requests accept either a
UUID-keyed scalar map or the legacy array (at most three entries). Map values are
strings, numbers, booleans, or null; nested objects and non-UUID keys are rejected. The
live test account rejects nonempty variant maps with an account-feature error, even
though they pass gateway validation. Do not infer that every tenant can use the object
format. Item responses can use either representation; the optional
X-Custom-Fields-Format header selects default (UUID-keyed object) or legacy
(name/value array) when the account supports both. Enabled account features determine
the default and whether a requested representation is accepted.
Keep missing fields, {}, and null distinct in serialization. An empty object is
valid input and must not be normalized to null. Add definition entity types only after
verifying their separate API contract; request-field support alone does not prove that a
definition can be created for that entity type. The portal's definition endpoint
documents the entity enum and feature requirements; the gateway leaves it as a bare
string. Use the shared spec's CustomFieldEntityType for the supported values.
Definition options may contain choices, appearsOn, or both. choices is required
for singleSelect, but must not be required for every options object. Variant
definitions can link to transactional entities via appearsOn; production-operation
definitions can link to manufacturing orders. Values snapshotted onto new records are
editable and are not re-synced after the source changes. Omitted option keys keep their
current values on update; an explicit empty appearsOn array removes all targets.
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.