Skip to main content

Discovery Contract — mazda (us)

The vetting gate between discovery and coding. This file is the human narrative + probe evidence; the authoritative gate state lives in the sidecar discovery-contract.gate.json, which public-api-implement checks via check_contract_gate.py. Keep this table in sync with the sidecar — every discovered endpoint/entity/FK in requirements must have a matching category:item gate, and every HARD row should read ✅ PASS or 🟡 OVERRIDE: <reason> (user-approved) before /public-api-implement <oem> may run. A Postman row may instead read 📦 MANUAL-IMPORT: <path> when the Postman MCP was unavailable and the collection was exported for hand-import — that state passes the gate but stays flagged. Probe markdown blocks are pasted in verbatim by public-api-contract-test.

  • OEM / market: mazda / us
  • Discovered: 2026-06-11
  • Gate status: ✅ PASS — all hard gates pass or carry a user-approved override (pagination:inventory overridden 2026-06-11). Coding may proceed.

Gate summary

#GateEntity / paramStatusEvidence
1Pagination contractinventory🟡 OVERRIDE (user-approved)pagination evidence below
2Enumerator (model)models✅ PASSenumerator_probe
3Enumerator (year)models✅ PASSenumerator_probe
4Enumerator (trim)features✅ PASSenumerator_probe
5Enumerator (dealer)dealers✅ PASSenumerator_probe
6Multi-value filterinventory⬜ N/Asingle-value fan-out per dealer-set
7Body shape / bot-blockinventory✅ PASSbody_shape (form-encoded)
8Body shape / bot-blockfeatures (configurator)✅ PASSbody_shape_probe
9Data quality / plausibilityinventory✅ PASSdata_quality_probe
10Data quality / plausibilitydealers✅ PASSdata_quality_probe
11Data quality / plausibilitymodels✅ PASSdata_quality_probe
12Data quality / plausibilityfeatures✅ PASSdata_quality_probe
13Cross-source identity keyinventory→dealers✅ PASSalignment + live match
14Cross-source identity keyinventory→models✅ PASSalignment + live match
15Cross-source identity keyinventory→features (one-to-many list FK)✅ PASSalignment + list-FK resolution verified
16Cross-source identity keyfeatures→models✅ PASSalignment + live match
17Real fixture (no synthetic)inventory✅ PASStests/data path
18Real fixture (no synthetic)dealers✅ PASStests/data path
19Real fixture (no synthetic)models✅ PASStests/data path
20Real fixture (no synthetic)features✅ PASStests/data path
21Postman recordinventory✅ PASSPostman request (set in stage 1)
22Postman recorddealers✅ PASSPostman request (set in stage 1)
23Postman recordmodels✅ PASSPostman request (set in stage 1)
24Postman recordfeatures✅ PASSPostman request (set in stage 1)

This summary covers the four core entities. Incentives is raw-only and is gate-checked separately in discovery-contract.gate.json (body_shape, fixture, postman all PASS).

Per-entity endpoints

EntityEndpoint (method + URL)Auth / protocolFixtureNotes
inventoryPOST https://www.mazdausa.com/api/inventorysearchnone; application/x-www-form-urlencoded + x-requested-with: XMLHttpRequestprojects/ai_mazda/tests/data/inventory_search_sample.jsondealer-scoped; ResultsStart pager is lossy — fetch one oversized page (ResultsPageSize >= TotalVehicles). PHP-array body keys (Vehicle[DealerId][]).
dealersGET https://www.mazdausa.com/handlers/dealer.ajax?p={page}noneprojects/ai_mazda/tests/data/dealer_locator_sample.jsonno zip param = national enumerator: paginate p (20/page) to body.total = 548 dealers / 28 pages. Identity results[].id. (A zip scopes+radius-caps; omit it.)
models (enumerator)GET https://www.mazdausa.com/api/bp/msnone; accept: application/jsonprojects/ai_mazda/tests/data/build_price_models_sample.jsonraw-only enumerator (no transformed tier). Full current-year lineup, one unfiltered call; codes under categories[].modelItemContainers[].newModel.modelCode. Sources the model-code universe for the trims + inventory fetches.
trims (raw-only driver)GET https://www.mazdausa.com/api/bp/ts?m={modelCode}none; accept: application/jsonprojects/ai_mazda/tests/data/build_price_trims_sample.jsonraw-only driver (no transformed tier). One call per upstream model code → response.trims[].code; resolves the valid t the configurator requires. A broken-upstream model returns {"response":{}} (no trims) — stored verbatim, yields no configurator pair.
configurator (→ MODELS + FEATURES)GET https://www.mazdausa.com/api/bp?m={modelCode}&t={trimCode}noneprojects/ai_mazda/tests/data/build_price_config_sample.jsonUpstream build_price_trims. One response sources both transformed entities: MODELS = response.trims[] (one row per trim, keyed code; carries inventorySearchModelCode + powertrain codes + basePrice); FEATURES = per-trim optionTrees exterior/interior/accessory codes joined to the model-level option catalogs (one row per option per trim, keyed feature_code, trim_code). Carries ALL trims regardless of t.
incentives (raw-only)GET https://www.mazdausa.com/api/incentives/{zip}noneprojects/ai_mazda/tests/data/incentives_sample.jsonRAW-tier only (no transformed/consolidated). body_shape + fixture + Postman vetted. Offers at body.regionalIncentives.{leaseOffers,purchaseOffers,cpoPurchaseOffers,specialOffers,specialOffersWithVehicles}; each carries carlineCode/modelCodeWithYear (FK informational, not a resolved identity key). Fan-out = one call per distinct upstream dealer zip (raw dealers is an upstream dep; zip is a required path segment — /api/incentives 404s), dedupe on marketingId. Per-model variant GET /api/incentives/{zip}/{modelCode}/{n} (trailing segment insignificant; zip is the scope).

Raw chain: build_price_models (enumerate model codes) → build_price_trims (resolve each model's trim t) → build_price_configurator (fetch /api/bp per resolved (model, trim); source MODELS + FEATURES).

Cross-source identity / FK alignment (HARD)

Value-matched (not name-matched) join keys. Each FK names the stable key that survives every feed.

Topology revised at implementation (user-approved). MODELS is built from the configurator at trim grain (key trim_code), so the model-level carline is no longer trim-unique and cannot be the inventory→models join key. The trim-unique inventorySearchModelCode (already carried per trim) is used instead, giving a trim-level inventory→models FK. FEATURES is re-grained to per-option-per-trim; inventory→features becomes a one-to-many list FK (Ford's feature_refs pattern) keyed (inventory_search_code, feature_code). The four FKs below are what the consolidated tier resolves.

FKReferencing entity.fieldTarget entity.fieldExample valueVerified
inventory→dealersinventory Vehicles[].DealerId (int)dealers results[].id (int)42166✅ live: 42166 present in dealer.ajax near zip 90232; same int type
inventory→modelsinventory Vehicles[].Model.InventorySearchCode (canonicalized)models inventory_search_code (← configurator trim inventorySearchModelCode, canonicalized)C70 PP XA✅ live: matches after canonicalizing (+→space, collapse runs, strip); unique per trim
features→modelsfeatures trim_code (← configurator optionTrees[].code)models trim_code (← configurator trims[].code)26C70PP✅ same configurator response — every feature trim_code has a matching model trim
inventory→features (list)inventory (inventory_search_code, feature_refs[].feature_code)features (inventory_search_code, feature_code)C70 PP XA + 48T✅ list FK verified via enrich_foreign_keys: feature_refs carries the vehicle's exterior/interior color codes, which resolve to the trim's feature row

Stable key per FK (resolved):

  • inventory→dealers: DealerId ≡ dealer id (integer dealer code; identical type both feeds).
  • inventory→models: canonicalized inventory-search code — inventory Model.InventorySearchCode and the configurator trim's inventorySearchModelCode are the same value in two encodings (spaces vs +); the join MUST canonicalize (+→space, collapse whitespace, strip) before matching. Trim-level and unique per model trim.
  • features→models: trim_code (e.g. 26C70PP) — both entities come from the same configurator response, so FEATURES rows always resolve to a MODELS trim.
  • inventory→features: one-to-many list FK on (inventory_search_code, feature_refs[].feature_code). Inventory builds feature_refs from each vehicle's exterior/interior color codes; the consolidated tier collects matched feature_ids into feature_ids. These color codes are Build & Price catalog entries for the trim, so they resolve. Factory option/package codes (Model.Options/Packages: AE1, SA3, 1PP) are a separate taxonomy and are excluded from feature_refs.

Probe evidence

1. Pagination contract — inventory

Pagination contract — 🔴 FAIL (genuine API limitation — OVERRIDE needed)

  • Endpoint: POST https://www.mazdausa.com/api/inventorysearch (form-urlencoded; baseline = 3 dealers, Vehicle[Carline][]=C70, TotalVehicles=17).
  • Attempted contract: ResultsStart (page param) + ResultsPageSize (size param).
  • The stock pagination_probe.py cannot drive this endpoint: it only sends json=body, but the API requires application/x-www-form-urlencoded with PHP-array keys (Vehicle[DealerId][]); a JSON body returns HTTP 500. Pagination was verified with a faithful form-encoded replica of the probe's algorithm (page through, dedupe by Vin, compare to TotalVehicles).
  • ResultsStart is a 1-based page number, but paging is lossy. With ResultsPageSize=5, page 1 and page 2 share 4 of 5 VINs (overlapping, non-disjoint pages); the full walk yields 11/17 unique. With ResultsPageSize=12, page 1 returns 12, page 2 returns 5 (all duplicates of page 1) → 12/17 unique, pages 3+ empty. The page param does advance (not strictly ignored) but never assembles the complete set — a silent truncation, the Ford signature.
  • Reliable contract = one oversized page, no paging. A single request with ResultsPageSize >= TotalVehicles (and ResultsStart=1) returns the complete, distinct set: PageSize=20→17/17; verified at scale on a 10-dealer NY set (PageSize=500/1000113/113, no cap hit), reproducible across runs. Because inventory is dealer-scoped, per-dealer-set result counts are small and known up front (TotalVehicles), so a count-then-single-fetch strategy is sound.
  • Resolution: record an OVERRIDE — paginated fetch via ResultsStart is unusable; the implementer must fetch unpaged with ResultsPageSize set above the reported TotalVehicles (size it from a small probe call, then refetch if the count grew). This is a real API behavior, not a discovery mistake; needs user approval.

2–5. Enumerators (model / year / trim / dealer)

Enumerator (model) — ✅ PASS

  • GET /api/bp/ms enumerates distinct newModel.modelCode values under response.categories[].modelItemContainers[].newModel.modelCode. Category 0 alone (the probe runs one list at a time) returns 8 codes: ['2650H','26C30','26C50','26C70','26C7P','26C90','26C9P','26CX5']. Full lineup flattens all 3 categories = 12 codes (26C70 26C7P 26C90 26C9P 26C30 26CX5 26C50 2650H 26M3H 26M3S 26MXR 26MX5). Iterate live; do not hardcode. (Note: the flat response.models[] array does NOT carry a clean modelCode field — only the categories[].modelItemContainers[].newModel path does.)

Enumerator (year) — ✅ PASS (with limitation)

  • GET /api/bp/ms newModel.year enumerates 1 distinct value live: 2026. The year field is real and read live, so the pipeline auto-tracks whatever model year Mazda publishes (no hardcoding). Limitation: prior-year model codes do not resolve — GET /api/bp/ts?m=25C70 and GET /api/bp?m=25C70&t=25C70PP both return HTTP 200 with an empty/null 15-byte body. The catalog is current-year-only by design; there is no historical-year fan-out. Not a defect (the enumerator reads year live), documented for the implementer.

Enumerator (trim) — ✅ PASS

  • GET /api/bp/ts?m=26C70 enumerates 5 distinct response.trims[].code values: ['26C70PF','26C70PP','26C70PR','26C70SPP','26C70SPR']. The richer /api/bp trims[] carries the same codes plus FK + structured prices. Iterate live; do not hardcode.

Enumerator (dealer) — ✅ PASS (national, no-arg)

  • GET /handlers/dealer.ajax with no zip param is the national enumerator — paginate p (20/page) to body.total. Verified live 2026-06-12: 548 unique results[].id across 28 pages, 49 states (== total == external ~547). Identity = results[].id.
  • A zip param scopes results and radius-caps them; per-zip total scales with radius and plateaus origin-dependently (NYC→108, LA→35, Chicago→50), so a wide radius never yields the national set. The earlier "no national enumerator / maxDistance=9999total=5 / zip-grid sweep" finding was wrong — corrected here and in dealers.py (no-arg pagination, US_NATIONAL_ZIP_SWEEP dependency dropped for dealers).

6. Multi-value filter — inventory

N/A — the inventory fan-out queries one dealer-set per call and one Carline/ModelCode value at a time; no need to combine multiple filter values in a single request, so cross-contamination is not a risk. (Vehicle[DealerId][] carries multiple dealer ids, but that is the intended dealer-scope, not a multi-value model/trim filter.)

7–8. Body shape / bot-block

Body shape / bot-block (inventory) — ✅ PASS

  • Endpoint: POST https://www.mazdausa.com/api/inventorysearch (form-urlencoded). The stock body_shape_probe.py only sends JSON bodies (HTTP 500 here), so a faithful form-encoded replica of its logic was run: 15 requests with a realistic UA + 15 with the default UA. 0% non-JSON 2xx in both batches, 0 errors. Akamai did not block clean form POSTs under load. A realistic UA is not strictly required, but the source module should still send ai_core.http.random_headers() and treat any non-JSON 2xx as a failed attempt; if Akamai later 403s, fall back to ai_core.http.browser_session seeded at https://www.mazdausa.com/.

Body shape / bot-block (configurator) — ✅ PASS

  • Endpoint: GET https://www.mazdausa.com/api/bp. Every 2xx response with a realistic UA parsed as JSON over 15 requests. Default-UA block rate 0%; realistic-UA block rate 0%. No Akamai block under load.

9–12. Data quality / plausibility

Data quality (inventory) — ✅ PASS (inventory_search_sample.json, 2 records)

FieldRuleImplausible %Verdict
Vinnonempty0%✅ ok
Pricepositive_number0%✅ ok
BaseMsrppositive_number0%✅ ok
DealerIdnonempty0%✅ ok
Carlinenonempty0%✅ ok
Model.InventorySearchCodenonempty0%✅ ok

Notes: DealerId/Carline/Model.InventorySearchCode are zero-variance — expected; the fixture is a single correlated CX-70 slice. Assess on full volume in the raw/transformed EDA loop.

Data quality (dealers) — ✅ PASS (dealer_locator_sample.json, 2 records)

FieldRuleImplausible %Verdict
idnonempty0%✅ ok
latlatitude0%✅ ok
longlongitude0%✅ ok
namenonempty0%✅ ok

Coordinates are real and populated (34.128, -118.255) — not the Hyundai all-0.0 failure.

Data quality (models) — ✅ PASS (build_price_models_sample.json, categories[0], 2 records)

FieldRuleImplausible %Verdict
newModel.modelCodenonempty0%✅ ok
newModel.yearpositive_number0%✅ ok
newModel.modelNamenonempty0%✅ ok

Data quality (configurator → MODELS + FEATURES) — ✅ PASS (build_price_config_sample.json, m=26M3S)

response.trims[] (5 records → 5 MODELS rows): code, inventorySearchModelCode, basePrice.amount (24650–36940) all 0% implausible; inventory_search_code unique per trim. optionTrees[] (5 trees → 156 FEATURES rows: 24 exterior / 7 interior / 125 accessory): feature_code, trim_code 0% null and unique as a pair; every option detail joins to a catalog entry. (Prices include legitimate 0 for no-charge options — not degenerate.)

13–16. Cross-source identity keys

See the FK alignment table and resolved-key list above. inventory→models resolves on the canonicalized trim-level inventorySearchModelCode (confirmed live — C70++PP+XA++ canonicalizes to the inventory VIN's C70 PP XA); features→models on trim_code (both from the same configurator response → 100% resolution in the M3S fixture); inventory→features as a one-to-many list FK on (inventory_search_code, feature_refs[].feature_code) (verified via enrich_foreign_keys: feature_refs carries the vehicle's color codes, which resolve to the trim's feature rows; factory option/package codes are excluded). FK1 (inventory→dealers) confirmed live against dealer.ajax.

17–20. Real fixtures

All real, live-captured responses under projects/ai_mazda/tests/data/:

EntityFixtureLive request
inventoryinventory_search_sample.jsonPOST /api/inventorysearch (Carline C70, dealers 42031/42231/42166)
dealersdealer_locator_sample.jsonGET /handlers/dealer.ajax?zip=90071
modelsbuild_price_models_sample.jsonGET /api/bp/ms
configuratorbuild_price_config_sample.jsonGET /api/bp?m=26M3S&t=26M3SPPT

All confirmed structurally real and representative; data_quality probes (above) ran clean on each. The configurator fixture is the full m=26M3S capture — all 5 trims with populated optionTrees, exterior/interior color catalogs, and the accessory catalog — so MODELS and FEATURES extraction and the features→models FK are exercised end-to-end against real data.

21–24. Postman records

Set in stage 1 (Postman MCP). All four entities recorded in collection 30657947-981c813f-173d-49cc-9215-3cab23957381:

  • inventory: request 30657947-c6091698-7b36-494f-c5d4-e016fea6cbf6
  • dealers: request 30657947-d97a3163-6af3-a79f-d31b-aacf4fe895e6
  • models: request 30657947-dc05ac15-a9cb-a4c9-e2d2-83a054ffe377
  • features: request 30657947-64c87b92-992f-8e69-8f35-8c52ef7e108d

Incentives (raw-only) added later as an incremental endpoint — folder 6. Incentives (raw-only), concrete request 30657947-ab16d849-75a0-445f-91b5-71f5446e411b (GET /api/incentives/90071) + parameterized variant on {{mazda_zip}}.

Overrides

Any HARD row not green must list an explicit, user-approved override here, or discovery is not done.

GateOVERRIDE reasonApproved by
pagination:inventoryResultsStart paging is lossy (overlapping, non-disjoint pages never assemble the full set — 11–12/17 unique). The reliable contract is a single unpaged fetch with ResultsPageSize >= TotalVehicles; verified complete at 113/113 on a larger dealer set with no size cap. Genuine API limitation, not a discovery mistake. Implementer must count-then-single-fetch (oversize the page) per dealer-set, never page via ResultsStart.✅ patrick.mcguire — 2026-06-11