# 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:

1. `scripts/regenerate_client.py` — emits the attrs models, API methods, and `client.py`
   under `katana_public_api_client/`.
1. `scripts/generate_pydantic_models.py` — emits the pydantic mirrors (and the sibling
   `Cached<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`](../../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:

```
ValueError: dictionary update sequence element #0 has length 1; 2 is required
```

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`](../../.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` → 200
- `POST /stock_transfers` → 200
- `POST /sales_return_rows` → 200
- `POST /inventory_reorder_points` → 200
- `POST /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_attrs` raising on `{}` from Katana → fix `from_attrs` codegen, not
  `_attrs_*_to_cached`.
- `Column(JSON)` failing to serialize a pydantic instance → fix `inject_json_columns` in
  `scripts/generate_pydantic_models.py`, not `sync.py`.
- Missing enum value → patch spec + regenerate, not enum-tolerant deserialization
  downstream.
