# Spec audit report — 2026-05-07

> **Archived 2026-05.** Point-in-time deliverable for #395 / #573 Phase 0. For the
> current state of spec drift, regenerate via `uv run poe audit-spec`,
> `uv run poe validate-response-examples`, and `uv run poe validate-examples`; the
> three-pass workflow is documented in
> [`docs/upstream-specs/README.md`](../../upstream-specs/README.md). The text below is
> preserved verbatim.

Phase 0 deliverable for #395 (and #573 epic). End-to-end audit of
`docs/katana-openapi.yaml` against the cached upstream specs in `docs/upstream-specs/`
plus the inline-example consistency checks. Captures *current state* — every
previously-known finding has been triaged and either closed by a fix-PR or carried
forward as a tracked issue.

## Methodology

Three audit passes, each backed by a poe task:

| Pass                        | Tooling                                 | Source of truth                                                                 |
| --------------------------- | --------------------------------------- | ------------------------------------------------------------------------------- |
| Request-side drift          | `uv run poe audit-spec`                 | `docs/upstream-specs/live-gateway.yaml` (Katana's live OpenAPI 3.0 gateway).    |
| Response-side example drift | `uv run poe validate-response-examples` | `docs/upstream-specs/readme-portal.yaml` (Katana's README.io developer portal). |
| Inline-example consistency  | `uv run poe validate-examples`          | The local spec itself (validates each `example:` block against its schema).     |

Cached upstream specs were last refreshed on 2026-05-06 via
`uv run poe refresh-upstream-spec`. See `docs/upstream-specs/README.md` for the
rationale on why we cache them.

## Source counts

- Live gateway: 110 unique paths, 209 path-method endpoints.
- Local spec: 110 unique paths, 209 path-method endpoints. **Path coverage matches.**
- Shared endpoints with named DTOs: 86.
- Endpoints with request-side drift: 5 (post-#570 / post-#578 baseline).
- Inline examples checked: 280, failures: 0 (post-fix in this PR).
- README.io response examples: drift confined to known stale-fixture cases.

## Phase 1 — fixed before this audit

The pre-existing 23-finding scratchpad in [`audit-2026-05-06.md`](audit-2026-05-06.md)
called out 23 findings (F1–F23). The following have shipped:

| Finding | Type                                                          | Closing PR / commit                                        |
| ------- | ------------------------------------------------------------- | ---------------------------------------------------------- |
| F1      | `/inventory variant_id` int→array                             | #570 → #579 (merged)                                       |
| F2–F4   | `batch_tracked` / `serial_tracked` boolean→string             | #570 → #579 (merged)                                       |
| F5      | `/recipes recipe_row_id` int→string                           | #570 → #579 (merged)                                       |
| F6      | `/stocktakes stock_adjustment_id` number→string               | #570 → #579 (merged)                                       |
| F20b    | `/bin_locations` bare-array response                          | (deferred) #575                                            |
| F22     | `StockTransferStatus` enum drift                              | (deferred) tracked under #573                              |
| F17     | `SalesOrderShippingFee.amount` number → README only           | local correct — README portal stale fixture, no fix needed |
| F18     | `/operators` bare-array vs wrapped                            | local correct — README stale fixture                       |
| F19     | `/purchase_order_accounting_metadata` camelCase → README only | local correct — README stale fixture                       |
| F21     | `/serial_numbers*` bare-array vs wrapped                      | local correct — README stale fixture                       |

## Phase 1 — current findings (this audit)

### Request-side drift (5 findings)

```
$ uv run poe audit-spec
- Live spec: 110 unique paths, 209 path-method endpoints
- Local spec: 110 unique paths, 209 path-method endpoints
- Shared endpoints with named DTOs: 86
- Endpoints with drift: 5 (0 live-only, 0 local-only)
```

| #   | Endpoint                                | Field               | Wire                             | Local                            | Triage                                |
| --- | --------------------------------------- | ------------------- | -------------------------------- | -------------------------------- | ------------------------------------- |
| 1   | `POST /manufacturing_order_productions` | `batch_transaction` | `object` (untyped)               | `$ref: BatchTransaction` (typed) | `local_correct_upstream_wrong` — keep |
| 2   | `POST /sales_orders`                    | `custom_fields`     | `object`                         | `array[CustomFieldValue]`        | `needs_verification` — see below      |
| 3   | `PATCH /sales_orders/{id}`              | `custom_fields`     | `object`                         | `array[CustomFieldValue]`        | `needs_verification` — see below      |
| 4   | `POST /sales_return_rows`               | `quantity`          | `number \| string` (allOf+anyOf) | `string` only                    | `needs_verification` — see below      |
| 5   | `PATCH /sales_return_rows/{id}`         | `quantity`          | `number \| string` (allOf+anyOf) | `string` only                    | `needs_verification` — see below      |

#### Finding 1: `manufacturing_order_productions.batch_transaction`

Upstream declares the field as untyped `object`; local declares a typed
`$ref: BatchTransaction`. The typed declaration is strictly more useful — the typed
client surfaces the field shape to callers — and downstream code already relies on it.
Classify as `local_correct_upstream_wrong`. No action.

#### Findings 2 & 3: `sales_orders.custom_fields`

Upstream declares an untyped `object` (`additionalProperties: true`); local declares
`array[CustomFieldValue]` (typed `{field_name, field_value}`). The local shape matches
the convention used elsewhere on the API for `custom_fields` (see `Variant`,
`CustomerAddress`, etc.). The bare `object` upstream may be a different *write*-shape,
or simply a permissive declaration that serializes the array to a JSON object
server-side.

**Action:** verify with a live-API write before changing either side. Filed as follow-up
issue.

#### Findings 4 & 5: `sales_return_rows.quantity`

Upstream accepts EITHER `number` OR a `string` matching the pattern
`^[0-9]+?\.?[0-9]*$`, with `anyOf` exclusions on zero / all-zero. The local spec only
declares `string`. Callers passing a Python `float` would currently fail the
typed-client signature, even though Katana would accept the JSON number on the wire.

**Action:** verify with a live-API write that `quantity: 1.5` works, then relax the
local spec to `oneOf: [number, string-pattern]` (or the simpler `number` if string is
just legacy). Filed as follow-up issue.

### Response-side drift (4 findings — all stale README fixtures)

```
$ uv run poe validate-response-examples
```

- `GET /serial_numbers → 200` — README portal returns bare array, local declares
  `{data: [...]}` wrapper. **Local correct** (verified live in audit-2026-05-06).
- `GET /serial_numbers_stock → 200` — same shape, same disposition.
- `PATCH /sales_order_shipping_fee/{id} → 200` — README portal example shows `amount: 0`
  (number), local declares `string`. **Local correct** (verified).
- `POST /sales_order_shipping_fee → 200` — same shape, same disposition.

All four are tracked as `stale_readme_example` in the override registry plan under #573.
None are real local-spec bugs.

### Inline-example consistency (1 finding — open question to verify)

`ManufacturingOrderOperationRow.example.type: "Production"` did not match the schema's
enum (`['process', 'setup', 'perUnit', 'fixed']`). The example value may actually be
what the live API returns — i.e. it's the *enum* that's wrong, not the example. Filed as
#583 for live-API verification.

For now, the example uses a value (`process`) that's consistent with the current enum so
`validate-examples` passes. When #583 verifies the truth, either the example or the enum
(and the example) will be updated to match.

Post-fix: 280/280 inline examples pass — and `validate-examples` is now wired into CI's
`quality` job.

## Phase 3 — recurring guardrail

Two pieces ship in this PR:

1. **`validate-examples` joins the `quality` job** in `.github/workflows/ci.yml`.
   Inline-example drift now blocks any PR that introduces a schema/example mismatch (≈1s
   runtime — cheap to add to every PR).

1. **New `.github/workflows/spec-drift-audit.yml` workflow.** Runs:

   - Weekly on a Monday cron (catches upstream changes we don't touch directly).
   - On every push/PR to `docs/katana-openapi.yaml` or `docs/upstream-specs/**` (catches
     local-spec edits that introduce drift).
   - On manual `workflow_dispatch`.

   Output: workflow summary populated with all three audit reports + 30-day artifact
   retention. Informational for now (`continue-on-error: true` on each step) — promoting
   to a strict gate is Phase 4 of the #573 epic (#425).

## Follow-up issues filed by this audit

- **#583** — verify whether `ManufacturingOperationType` enum should include capitalised
  values like `Production` (live-API verification needed). This audit's example fix is a
  temporary alignment; #583 may invalidate it.
- **#584** — verify the 4 needs-verification request-side findings against a live
  sandbox: `sales_orders.custom_fields` (object vs array) ×2 and
  `sales_return_rows.quantity` (number-or-string vs string-only) ×2.

## Phase 4 — remaining work (out of scope for this PR)

Tracked under `#573` epic:

- **#425** — wire `audit-spec --strict` into CI as a blocking gate (Phase 4).
- **#573 Phase 1** — populate `docs/upstream-specs/audit-overrides.yaml` with the
  documented `local_correct_upstream_wrong` and `stale_readme_example` cases so
  `audit-spec --strict` doesn't false-positive on them.
- **#573 Phase 5** — `docs/upstream-bugs.md` catalog of upstream bugs we'd recommend
  Katana fix.

## How to run this audit yourself

```bash
uv run poe refresh-upstream-spec        # optional: refresh cached upstream
uv run poe audit-spec                   # request-side drift
uv run poe validate-response-examples   # response-side drift (README portal)
uv run poe validate-examples            # inline consistency
```

Rerun after each spec edit and bundle the regen output into the same commit per
CLAUDE.md "Generator/schema edits without committing the regen".
