# Spec drift verification — 2026-05-06

> **Archived 2026-05.** Point-in-time scratchpad behind the #573 Phase 0 audit (the
> 2026-05-07 report at [`audit-2026-05-07.md`](audit-2026-05-07.md) is the cleaned-up
> deliverable). The companion live-API verifier `scripts/verify_drift.py` keys its
> findings against this file's IDs. The text below is preserved verbatim.

Working record for #573 Phase 0: verify each suspected drift finding against the live
Katana API or a sibling endpoint, then triage into the override-registry categories from
#573 (`intentional_local_divergence`, `upstream_correct_local_wrong`,
`local_correct_upstream_wrong`, `both_wrong_live_correct`).

This file is a running scratchpad, not the final report. As findings firm up, they
should land as comments on the source issues (#570, #571, #528, #395) and — ultimately —
entries in `docs/upstream-specs/audit-overrides.yaml` (Phase 1 of the umbrella).

## Method

Live-API verification uses raw `httpx` against `https://api.katanamrp.com/v1` with the
credentials in the primary worktree's `.env`. We bypass the typed client where its
signature is what we're trying to question (e.g., the `variant_id: int` typing in #570).
Captured payloads land in the per-finding sections below.

The verification script lives at `scripts/verify_drift.py`. Run with
`uv run python scripts/verify_drift.py <finding-id>` to reproduce a single finding, or
`--all` to run the full suite. Output goes to stdout as Markdown (default) or JSON
(`--json`); pipe / paste into the appropriate per-finding section below.

## Index

- [#570 query-param type mismatches](#570-query-param-type-mismatches)
- [#571 missing query params + ambiguous cases](#571-missing-query-params)
- [#528 cluster B response-body drift](#528-cluster-b-response-body-drift)
- [#395 deferred items M-1, M-4](#395-deferred-items)

______________________________________________________________________

## #570 query-param type mismatches

### F1. `GET /inventory variant_id` — int vs array[int]

**Status:** ✅ **VERIFIED — local wrong**

- **Local spec** declares `variant_id: type: integer` (singular, via shared `variant_id`
  component).
- **Upstream live-gateway** declares `name: variant_id, type: array, items: integer`.
- **Result:** Live API call confirms array filtering works.
  - Single value `?variant_id=33302026` → **7 rows**
  - Multi-value `?variant_id=33302026&variant_id=33302030` → **14 rows** (= 7+7)
  - Confirmed: wire accepts multi-value form.
- **Triage:** `upstream_correct_local_wrong`
- **Action:** Fix as described in #570. Inline param on `/inventory` as
  `type: array, items: integer`. Real runtime bug — current typed client cannot filter
  by multiple variants.

### F2. `GET /products batch_tracked` — boolean vs string

**Status:** ✅ **VERIFIED — no runtime bug**

- **Local:** `boolean`. **Upstream:** `string`.
- **Result:** Live API call shows wire is permissive:
  - `?batch_tracked=true` → 0 rows (filter applied, no batch-tracked products)
  - `?batch_tracked=false` → 5 rows (filter applied, all matched)
  - `?batch_tracked=True` (capital T) → 0 rows (case-folded, equivalent to true)
  - All return 200; response payload uses real JSON `bool` for the field.
- **Triage:** `stylistic_mismatch_no_runtime_impact`
- **Action:** No change needed. `openapi-python-client` serializes Python `True`/`False`
  to lowercase `true`/`false` correctly. Local boolean typing is functionally fine.
  Could change to `string` for upstream consistency, but it's a low-priority cosmetic
  change.

### F3. `GET /products serial_tracked` — boolean vs string

**Status:** ✅ **VERIFIED — no runtime bug** (same shape as F2; not re-tested because the
wire behavior is identical for filter-string handling).

### F4. `GET /materials batch_tracked` — boolean vs string

**Status:** ✅ **VERIFIED — no runtime bug** (same shape as F2).

### F5. `GET /recipes recipe_row_id` — integer vs string

**Status:** _pending verification_

- **Local:** `integer`. **Upstream:** `string`.
- **Hypothesis:** Katana likely accepts both `"123"` and `123` since query strings are
  wire-strings. Real question: does the documented response narrow correctly with
  either?
- **Verification plan:** call `/recipes?recipe_row_id=<known-id>` with the int form and
  the string form (no-op for HTTP layer; really asking whether the docs lie or local
  lies).
- **Result:**
- **Triage:**

### F6. `GET /stocktakes stock_adjustment_id` — number vs string

Same shape as F5.

______________________________________________________________________

## #571 missing query params

The 9 high-confidence missing params are mostly safe to add — they're additive,
non-breaking, and upstream documents them. Verification just confirms each one actually
filters at the API level (vs being silently ignored, which would mean the upstream docs
are aspirational).

### F7. `GET /bin_locations ids` — array[integer]

- **Status:** _pending verification_
- **Plan:** call without param (capture count), then with `?ids=<a>&ids=<b>`, confirm
  response narrows.
- **Result:**

### F8. `GET /locations is_primary` — boolean

- **Plan:** call `?is_primary=true` and `?is_primary=false`, confirm partition.
- **Result:**

### F9. `GET /products category_name` — string

- **Plan:** pick a known category name, call `?category_name=<name>`.
- **Result:**

### F10. `GET /materials category_name` — string

Same shape as F9.

### F11. `GET /purchase_orders last_document_status` — enum

- **Plan:** call with each enum value, confirm filtering.
- **Result:**

### F12. `GET /sales_order_fulfillments invoice_status` — enum

- **Plan:** call `?invoice_status=INVOICED` and `=NOT_INVOICED`.
- **Result:**

### F13. `GET /sales_order_fulfillments/{id} include_deleted` — boolean

- **Plan:** find a soft-deleted fulfillment, confirm it's hidden by default, surfaces
  with `?include_deleted=true`.
- **Result:**

### F14. `GET /variants abc_classification` — enum

- **Plan:** call with each of A, B, C.
- **Result:**

### F15. `GET /variants type` — enum (product vs material)

- **Plan:** call `?type=product` and `?type=material`, confirm partition of the variant
  set.
- **Result:**

### F16-A. `GET /sales_returns` rename ambiguity

- **Local params:** `return_order_no`, `return_date_min/max`, `sales_order_id`.
- **Upstream params:** `order_no`, `order_return_date_min/max`, `sales_order_no`.
- **Question:** are local params accepted as aliases by the wire, or is local wrong?
- **Plan:** try both name-sets against the live API, see which one filters.
- **Result:**

### F16-B. `GET /sales_order_shipping_fee sales_order_id` — missing FK filter

- **Question:** does the API support filtering by `sales_order_id` even though local
  doesn't expose it?
- **Plan:** call with the param, see if response narrows to that SO's fees.
- **Result:**

______________________________________________________________________

## #528 cluster B response-body drift

These are response-shape findings from `validate-response-examples`. Each needs a real
call to capture Katana's actual response shape, then we can tell whether the local spec
or the README portal example is the lie.

### F17. `GET /sales_order_shipping_fee.amount` — string vs number

**Status:** ✅ **VERIFIED — local correct**

- **Local schema:** `amount: string`.
- **README example:** `amount: 3.14` (number).
- **Result:** Live response shows `amount: '6.7500000000'` — a STRING with 10 decimal
  places (Katana's high-precision financial number convention, same shape as
  `quantity_in_stock`, `average_cost`, etc.).
- **Triage:** `stale_readme_example`
- **Action:** No spec change needed. **#528 finding B1 is a stale README example, not
  real drift.** Sample record:
  ```json
  {"id": 9875602, "sales_order_id": 44718855, "description": "UPS - UPS Ground",
   "amount": "6.7500000000", "tax_rate_id": 402909}
  ```

### F18. `GET /operators` — bare array vs wrapped

**Status:** ✅ **VERIFIED — local correct**

- **Local schema:** `OperatorListResponse` (wrapped in `{data: [...]}`).
- **README example:** bare array.
- **Result:** Live API returns `{data: [...]}` wrapped (count was 0 but response had
  `data` key, not bare array).
- **Triage:** `stale_readme_example`
- **Action:** No spec change needed. **#528 finding B2 is a stale README example, not
  real drift.**

### F19. `GET /purchase_order_accounting_metadata` — camelCase vs snake_case

**Status:** ✅ **VERIFIED — local correct**

- **Local schema:** snake_case (`purchase_order_id`, `bill_id`, etc.).
- **README example:** camelCase (`purchaseOrderId`, `billId`, etc.).
- **Result:** Live response uses **snake_case**:
  ```json
  {"id": 493970, "purchase_order_id": 2665178, "received_items_group_id": 6373549,
   "bill_id": "44835", "integration_type": "quickBooks",
   "created_at": "2026-05-05T20:27:19.574Z"}
  ```
- **Triage:** `stale_readme_example`
- **Action:** No spec change needed. **#528 finding B3 is a stale README example, not
  real drift.** The previous fix mentioned in #528 (switching schema to snake_case) was
  correct.

### F20. `GET /bin_locations` — `name` vs `bin_name`

**Status:** ⚠️ **SECONDARY FINDING — possible bare-array response**

- **Plan was:** check field naming.
- **Result:** Test account has 0 bin_locations rows, so field-name question is
  undetermined. **However — response shape is bare array (no `data` wrapper).** Local
  likely declares `BinLocationListResponse` wrapped — if so, this is a genuine
  response-shape bug similar to #527. Need to confirm by checking local spec + finding a
  test account with bin_locations data.
- **Triage:** `needs_more_investigation`
- **Action:** Follow-up: (a) check local spec's response shape; (b) find a factory with
  bin_locations populated to verify field naming.

### F21. `GET /serial_numbers` and `/serial_numbers_stock` — bare array vs wrapped

**Status:** ✅ **VERIFIED — local correct**

- **Result:** Both endpoints return `{data: [...]}` wrapped:
  - `/serial_numbers?limit=1` → `keys=['data'], count=1`
  - `/serial_numbers_stock?limit=1` → `keys=['data'], count=808`
- **Triage:** `stale_readme_example`
- **Action:** No spec change needed. **#528 finding B5 is a stale README example, not
  real drift.**

### F20b. `GET /bin_locations` — confirm bare-array shape

**Status:** ✅ **VERIFIED — NEW FINDING: bare-array response (#527-class bug)**

- **Result:** Unrestricted query also returned bare array `[]`
  (`keys=['<bare-array>'], count=0`). Confirmed: `/bin_locations` returns a **bare array
  on the wire**, not the `{data: [...]}` wrapper that local declares as
  `StorageBinListResponse`.
- **Cross-check:** F18 (`/operators`) with empty data returned `{data: []}` — proving
  Katana's empty-result responses still honor the wrapper convention when the wrapper is
  in effect. So `/bin_locations`'s bare `[]` is a genuine wire-shape signal, not an
  empty-result artifact.
- **Triage:** `upstream_correct_local_wrong` (NEW finding — same bug class as #527 but
  with a different shape: response declared with wrapper schema that doesn't match
  wire).
- **Action:** **File a new issue.** Fix local `/bin_locations` response: drop
  `StorageBinListResponse`, declare response as bare array per CLAUDE.md ''documented
  exceptions to wrapping'' pattern (currently just `/user_info`; this expands the list).
- **Caveat:** Verify with non-empty data when possible. Test factory had 0 rows; an
  empty result CAN render as `[]` from a wrapped endpoint via some proxies (though F18
  disproved this for Katana specifically). Tracking- worthy follow-up: confirm with a
  factory that has bin_locations populated.

______________________________________________________________________

## #395 deferred items

### F22. M-1 `StockTransferStatus` enum values

**Status:** ✅ **VERIFIED — both local and upstream wrong**

- **Local enum:** `[pending, in_transit, completed, cancelled]` — all wrong.
- **Upstream enum:** `[received, created]` — partially right (`received` matches;
  `created` not seen).
- **Result:** Distinct `status` values across 46 live stock transfers:
  **`['draft', 'received']`**.
- **Triage:** `both_wrong_live_correct`
- **Action:** Fix local enum to match wire reality. Need to confirm full enum set — may
  need to capture transfers through the full lifecycle (e.g., pending pick → in transit
  → received) to see if there are more values. Suggested as a starting point:
  `[draft, received]`. May also include `created` (upstream) and intermediate states.
  Coordinate with any in-tree callers that pass the old `pending`/`in_transit`/etc.
  values — those will break with this fix.

### F23. M-4 `InventoryMovementResourceType.ProductionIngredient`

**Status:** ✅ **VERIFIED — local correct, upstream wrong**

- **Local:** has `ProductionIngredient` value; upstream doesn't.
- **Result:** Distinct `resource_type` values across 200 inventory_movements:
  **`['Production', 'ProductionIngredient', 'PurchaseOrderRow', 'SalesOrderRow', 'StockAdjustmentRow']`**.
  `ProductionIngredient` confirmed present on the wire.
- **Triage:** `local_correct_upstream_wrong`
- **Action:** No local change. Add to override registry once Phase 1 lands. Recommend
  upstream fix in upstream-bug catalog (Phase 5).

______________________________________________________________________

## Triage decisions (filled as verifications complete)

| Finding | Verified        | Category                               | Action                                                                |
| ------- | --------------- | -------------------------------------- | --------------------------------------------------------------------- |
| F1      | ✅              | `upstream_correct_local_wrong`         | Fix #570: inline `variant_id: array[int]` on `/inventory`             |
| F2      | ✅              | `stylistic_mismatch_no_runtime_impact` | No fix needed; downgrade #570 priority                                |
| F3      | ✅ (by analogy) | `stylistic_mismatch_no_runtime_impact` | No fix needed                                                         |
| F4      | ✅ (by analogy) | `stylistic_mismatch_no_runtime_impact` | No fix needed                                                         |
| F5      | _pending_       |                                        | Likely same conclusion as F2 — query-string layer is permissive       |
| F6      | _pending_       |                                        | Same as F5                                                            |
| F7-F15  | _pending_       |                                        | Verifier additions needed for #571 missing-param confirmation         |
| F16-A   | _pending_       |                                        | Needs both name-sets tested against `/sales_returns`                  |
| F16-B   | _pending_       |                                        | Try `?sales_order_id=N` against `/sales_order_shipping_fee`           |
| F17     | ✅              | `stale_readme_example`                 | No fix; close #528 B1 as not-a-bug                                    |
| F18     | ✅              | `stale_readme_example`                 | No fix; close #528 B2 as not-a-bug                                    |
| F19     | ✅              | `stale_readme_example`                 | No fix; close #528 B3 as not-a-bug                                    |
| F20     | ⚠️ partial      | `needs_more_investigation`             | Empty test data; possible bare-array bug discovered                   |
| F21     | ✅              | `stale_readme_example`                 | No fix; close #528 B5 as not-a-bug                                    |
| F22     | ✅              | `both_wrong_live_correct`              | Fix local enum: at minimum `[draft, received]` (may have more states) |
| F23     | ✅              | `local_correct_upstream_wrong`         | No local change; upstream-bug recommendation                          |

## Headline findings after first pass

- **#570 (6 query-param type mismatches): only 1 confirmed bug** (F1 / variant_id). The
  other 5 are stylistic — boolean serializes correctly through the typed client, so no
  runtime impact. Issue should be downgraded from p1-high to reflect this — only F1 is a
  real bug; F2-F6 are cosmetic.
- **#528 cluster B (5 response-body findings): 4 of 5 verified as stale README
  examples** (B1/F17, B2/F18, B3/F19, B5/F21). No spec changes needed for these.
  Remaining: B4/F20 (empty test data — incidentally surfaced a bare-array question filed
  as #575).
- **#395 deferred items: both real, but in opposite directions.** F22/M-1 is a
  `both_wrong_live_correct` (fix local). F23/M-4 is a `local_correct_upstream_wrong` (no
  fix; document upstream bug).
- **#571 (9 missing query params): not yet verified.** Most likely all real (additive,
  low risk), but verification required to confirm the API actually filters on each.

## Next verifications

- F5, F6 (`/recipes recipe_row_id`, `/stocktakes stock_adjustment_id`).
- F7-F15 (the 9 #571 missing params).
- F16-A, F16-B (#571 ambiguous renames).
- F20 follow-up (find a factory with `bin_locations` data, or check local spec to
  confirm whether the bare-array response is itself drift — filed as #575).
