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, whichpublic-api-implementchecks viacheck_contract_gate.py. Keep this table in sync with the sidecar — every discovered endpoint/entity/FK inrequirementsmust have a matchingcategory:itemgate, and every HARD row should read✅ PASSor🟡 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 bypublic-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:inventoryoverridden 2026-06-11). Coding may proceed.
Gate summary
| # | Gate | Entity / param | Status | Evidence |
|---|---|---|---|---|
| 1 | Pagination contract | inventory | 🟡 OVERRIDE (user-approved) | pagination evidence below |
| 2 | Enumerator (model) | models | ✅ PASS | enumerator_probe |
| 3 | Enumerator (year) | models | ✅ PASS | enumerator_probe |
| 4 | Enumerator (trim) | features | ✅ PASS | enumerator_probe |
| 5 | Enumerator (dealer) | dealers | ✅ PASS | enumerator_probe |
| 6 | Multi-value filter | inventory | ⬜ N/A | single-value fan-out per dealer-set |
| 7 | Body shape / bot-block | inventory | ✅ PASS | body_shape (form-encoded) |
| 8 | Body shape / bot-block | features (configurator) | ✅ PASS | body_shape_probe |
| 9 | Data quality / plausibility | inventory | ✅ PASS | data_quality_probe |
| 10 | Data quality / plausibility | dealers | ✅ PASS | data_quality_probe |
| 11 | Data quality / plausibility | models | ✅ PASS | data_quality_probe |
| 12 | Data quality / plausibility | features | ✅ PASS | data_quality_probe |
| 13 | Cross-source identity key | inventory→dealers | ✅ PASS | alignment + live match |
| 14 | Cross-source identity key | inventory→models | ✅ PASS | alignment + live match |
| 15 | Cross-source identity key | inventory→features (one-to-many list FK) | ✅ PASS | alignment + list-FK resolution verified |
| 16 | Cross-source identity key | features→models | ✅ PASS | alignment + live match |
| 17 | Real fixture (no synthetic) | inventory | ✅ PASS | tests/data path |
| 18 | Real fixture (no synthetic) | dealers | ✅ PASS | tests/data path |
| 19 | Real fixture (no synthetic) | models | ✅ PASS | tests/data path |
| 20 | Real fixture (no synthetic) | features | ✅ PASS | tests/data path |
| 21 | Postman record | inventory | ✅ PASS | Postman request (set in stage 1) |
| 22 | Postman record | dealers | ✅ PASS | Postman request (set in stage 1) |
| 23 | Postman record | models | ✅ PASS | Postman request (set in stage 1) |
| 24 | Postman record | features | ✅ PASS | Postman 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
| Entity | Endpoint (method + URL) | Auth / protocol | Fixture | Notes |
|---|---|---|---|---|
| inventory | POST https://www.mazdausa.com/api/inventorysearch | none; application/x-www-form-urlencoded + x-requested-with: XMLHttpRequest | projects/ai_mazda/tests/data/inventory_search_sample.json | dealer-scoped; ResultsStart pager is lossy — fetch one oversized page (ResultsPageSize >= TotalVehicles). PHP-array body keys (Vehicle[DealerId][]). |
| dealers | GET https://www.mazdausa.com/handlers/dealer.ajax?p={page} | none | projects/ai_mazda/tests/data/dealer_locator_sample.json | no 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/ms | none; accept: application/json | projects/ai_mazda/tests/data/build_price_models_sample.json | raw-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/json | projects/ai_mazda/tests/data/build_price_trims_sample.json | raw-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} | none | projects/ai_mazda/tests/data/build_price_config_sample.json | Upstream 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} | none | projects/ai_mazda/tests/data/incentives_sample.json | RAW-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-levelcarlineis no longer trim-unique and cannot be the inventory→models join key. The trim-uniqueinventorySearchModelCode(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'sfeature_refspattern) keyed(inventory_search_code, feature_code). The four FKs below are what the consolidated tier resolves.
| FK | Referencing entity.field | Target entity.field | Example value | Verified |
|---|---|---|---|---|
| inventory→dealers | inventory Vehicles[].DealerId (int) | dealers results[].id (int) | 42166 | ✅ live: 42166 present in dealer.ajax near zip 90232; same int type |
| inventory→models | inventory 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→models | features 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≡ dealerid(integer dealer code; identical type both feeds). - inventory→models: canonicalized inventory-search code — inventory
Model.InventorySearchCodeand the configurator trim'sinventorySearchModelCodeare 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 buildsfeature_refsfrom each vehicle's exterior/interior color codes; the consolidated tier collects matchedfeature_ids intofeature_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 fromfeature_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.pycannot drive this endpoint: it only sendsjson=body, but the API requiresapplication/x-www-form-urlencodedwith 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 byVin, compare toTotalVehicles). ResultsStartis a 1-based page number, but paging is lossy. WithResultsPageSize=5, page 1 and page 2 share 4 of 5 VINs (overlapping, non-disjoint pages); the full walk yields 11/17 unique. WithResultsPageSize=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(andResultsStart=1) returns the complete, distinct set:PageSize=20→17/17; verified at scale on a 10-dealer NY set (PageSize=500/1000→113/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
ResultsStartis unusable; the implementer must fetch unpaged withResultsPageSizeset above the reportedTotalVehicles(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/msenumerates distinctnewModel.modelCodevalues underresponse.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 flatresponse.models[]array does NOT carry a cleanmodelCodefield — only thecategories[].modelItemContainers[].newModelpath does.)
Enumerator (year) — ✅ PASS (with limitation)
GET /api/bp/msnewModel.yearenumerates 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=25C70andGET /api/bp?m=25C70&t=25C70PPboth 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=26C70enumerates 5 distinctresponse.trims[].codevalues:['26C70PF','26C70PP','26C70PR','26C70SPP','26C70SPR']. The richer/api/bptrims[]carries the same codes plus FK + structured prices. Iterate live; do not hardcode.
Enumerator (dealer) — ✅ PASS (national, no-arg)
GET /handlers/dealer.ajaxwith nozipparam is the national enumerator — paginatep(20/page) tobody.total. Verified live 2026-06-12: 548 uniqueresults[].idacross 28 pages, 49 states (==total== external ~547). Identity =results[].id.- A
zipparam scopes results and radius-caps them; per-ziptotalscales 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=9999→total=5/ zip-grid sweep" finding was wrong — corrected here and indealers.py(no-arg pagination,US_NATIONAL_ZIP_SWEEPdependency 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 stockbody_shape_probe.pyonly 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 sendai_core.http.random_headers()and treat any non-JSON 2xx as a failed attempt; if Akamai later 403s, fall back toai_core.http.browser_sessionseeded athttps://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)
| Field | Rule | Implausible % | Verdict |
|---|---|---|---|
Vin | nonempty | 0% | ✅ ok |
Price | positive_number | 0% | ✅ ok |
BaseMsrp | positive_number | 0% | ✅ ok |
DealerId | nonempty | 0% | ✅ ok |
Carline | nonempty | 0% | ✅ ok |
Model.InventorySearchCode | nonempty | 0% | ✅ 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)
| Field | Rule | Implausible % | Verdict |
|---|---|---|---|
id | nonempty | 0% | ✅ ok |
lat | latitude | 0% | ✅ ok |
long | longitude | 0% | ✅ ok |
name | nonempty | 0% | ✅ 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)
| Field | Rule | Implausible % | Verdict |
|---|---|---|---|
newModel.modelCode | nonempty | 0% | ✅ ok |
newModel.year | positive_number | 0% | ✅ ok |
newModel.modelName | nonempty | 0% | ✅ 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/:
| Entity | Fixture | Live request |
|---|---|---|
| inventory | inventory_search_sample.json | POST /api/inventorysearch (Carline C70, dealers 42031/42231/42166) |
| dealers | dealer_locator_sample.json | GET /handlers/dealer.ajax?zip=90071 |
| models | build_price_models_sample.json | GET /api/bp/ms |
| configurator | build_price_config_sample.json | GET /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.
| Gate | OVERRIDE reason | Approved by |
|---|---|---|
| pagination:inventory | ResultsStart 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 |