Skip to main content

Discovery Contract — porsche (PCNA)

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 HARD row should read ✅ PASS or 🟡 OVERRIDE: <reason> (user-approved) before /public-api-implement porsche may run. Probe markdown blocks are pasted in verbatim by public-api-contract-test.

  • OEM / market: porsche / PCNA (US)
  • Discovered: 2026-06-10
  • Gate status: 🟡 all gates resolved — 2 hard overrides required (pagination cap + identity correction)

Gate summary

#GateEntity / paramStatusEvidence
1Pagination contractfinder_inventory🟡 OVERRIDEHard cap at page 100 per query. Superseded 2026-07-12: dealership={ppnOrgId} query param scopes the search to one dealer's full stock (all ranges), staying far under the cap. Approved: dealer × condition fan-out.
2Enumerator (model)mpi_models → modelType✅ PASS82 unique modelTypes, single endpoint, no pagination
3Enumerator (model series)hitcounts → modelSeries✅ PASS12 model series, single endpoint. No longer drives ingestion (dealership-scoped search returns all ranges at once)
4Enumerator (dealer/seller)hitcounts → sellers✅ PASS207 sellers with inventory. Ingestion fan-out uses dealersearch ppnOrgIds instead (same id space)
5Multi-value filterinventory✅ N/AOne value per request: condition/dealership/model filter the bare /search route (verified 2026-07-12); span conditions by calling once per value
6Body shape / bot-blockfinder_inventory (RSC)✅ PASSStable under repeated requests; realistic UA required (429 without)
7Body shape / bot-blockmpi_models✅ PASSPure JSON API, stable, no UA required
8Body shape / bot-blockfinder_hitcounts✅ PASSJSON API, stable; realistic UA required (429 without)
9Body shape / bot-blockdealersearch_dealers✅ PASSSSR HTML, stable, UA not enforced
10Cross-source identity keysall FKs🟡 OVERRIDESee identity correction: (modelCode, featureCode) is wrong — correct is (listingId, equipmentCode)
11Real fixtures (no synthetic)all entities✅ PASSAll fixtures captured from live API in tests/data/

Per-entity endpoints

EntityEndpoint (method + URL)Auth / protocolNotes
modelsGET https://mpi.pccompare.aws.porsche-preview.cloud/markets/{marketCode}/models/None, open CORSPure JSON; per-market catalog (82 for PCNA); no pagination. GET /markets/ lists all market codes + locales
inventoryGET https://finder.porsche.com/{market}/{locale}/search?page={n}&position={all-locations}UA required (via Zyte)Next.js RSC (RSC: 1 header → clean JSON stream); single national sweep — a market-neutral position=World,0,0,99999 defeats the IP-derived ~50mi geo filter so one 1-based paginated pass returns the full market catalog (page count from the first page's pages.totalPages); no dealership/condition/model filter and no enumerator upstream. Servable results plateau ~1216 for US (deep pages stop serving) — accepted; see pagination gate
inventory_detailGET https://finder.porsche.com/{market}/{locale}/details/{listingUrlSlug}UA required (via Zyte)Next.js RSC; per-listing detail (a richer view of the same listing) — equipment.specialEquipment[*].items[*], merged onto inventory on listing_id. Not a separate entity
featuresGET https://configurator.porsche.com/{locale}/mode/model/{modelCode}.data?_routes=customer-configuratorNone (CloudFront)React Router single-fetch turbo-stream; per-model option catalog (views.search.options), grain (feature_code, model_code, model_year); MPI codes drive the fan-out; 404 = not configurable (suppressed)
hitcountsGET https://finder.porsche.com/api/us/en-US/hitcountsUA requiredPure JSON; 207 sellers with inventory + 12 model series; no pagination. No longer drives ingestion (national sweep)
dealersGET https://www.porsche.com/{market}/{locale}/dealersearch/NoneAstro SSR HTML; dealers embedded as HTML-encoded JSON (&quot;); 217 unique dealers (434 occurrences = 2× per dealer)

Cross-source identity / FK alignment (HARD)

Value-matched (not name-matched) join keys, proven by live sampling.

FKReferencing entity.fieldTarget entity.fieldExample valueVerified
inventory→dealersinventory.ppnOrgIddealers.ppnOrgId16927✅ 100% match (3-page sample)
inventory→dealers (alt)inventory.companyIddealers.porschePartnerNo4501567✅ 100% match (3-page sample)
inventory→modelsinventory.modelCodempi_models.modelType95BAU1, 982120✅ 27/33 seen codes match; 6 historical codes (e.g., 991450) not in current PCNA catalog
hitcounts→dealershitcounts.sellers[].keydealers.ppnOrgId16927✅ 206/207 match; seller 2795 (1 vehicle) has no dealer record — private seller
inventory_detail→inventoryinventory_detail.listingIdinventory.idV9Z695✅ Detail page slug encodes the listing ID
features→modelsfeatures.model_code (configurator catalog.orderType)mpi_models.modelType992892✅ 1:1 by construction — MPI codes drive the configurator fan-out (2026-07-17)

Identity key correction (OVERRIDE required)

The explore stage proposed (modelCode, featureCode) as the finder detail features identity. This is wrong for that source.

Equipment codes (code field, e.g. PU5, 3FU) are per-listing options — they vary by vehicle, not by model. Two Macan listings on the same day with the same modelCode have overlapping but different equipment codes. The correct grain identity is (listingId, equipmentCode):

  • Listing V9Z695 (modelCode 95BAU1): 91 equipment codes
  • Listing DR5PRE (modelCode 95BBV1): 91 equipment codes; 78 overlap, 13 unique to each
  • The code field values are Porsche option codes (e.g. PU5 = Premium Package Plus, 3FU = Panoramic Roof)

Superseded scope 2026-07-17: the finder /details grain now backs the inventory_detail source — a richer view of the same inventory listing, merged onto inventory on listing_id — rather than a features entity. The features entity is re-sourced from the Car Configurator at the per-model catalog grain (feature_code, model_code, model_year) (GET configurator.porsche.com/{locale}/mode/model/{modelCode}.data?_routes=customer-configurator, React Router turbo-stream; no UA enforcement; verified en-US + de-CH against browser-hydrated ground truth). Full sweep over all 74 live MPI codes: one payload shape throughout (67–250 options/model), deterministic bodies, and 11/74 en-US (15/74 de-CH) codes 404 — end-of-production, market-absent, or sold-out models still in MPI, so a 404 is an expected absence (suppressed in the fetch), not a failure. See body_shape:configurator_features and identity_key:features->models in the gate sidecar.

Postman follow-up: the postman:features gate still points at the finder 4. Finder Vehicle Detail / Features folder, which now documents inventory_detail. The Configurator features endpoint needs its own Postman collection entry — pending (the Postman MCP was not available when the re-sourcing landed).

Probe evidence

1. Pagination contract — finder_inventory

{
"probe": "pagination",
"passed": false,
"entity": "finder_inventory",
"url": "https://finder.porsche.com/us/en-US/search",
"reported_total_pages": 1843,
"page_style": "page",
"page_base": 1,
"hard_cap": 100,
"items_per_page": 15,
"claimed_total_items": 27645,
"accessible_items_unfiltered": 1500,
"accessible_items_per_series": 7965,
"coverage_per_series_pct": 28.7,
"page0_normalizes_to_page1": true,
"page_101_returns_empty": true,
"overflow_clamped_to_page100": true
}

VERDICT: FAIL (series-only) — APPROVED WORKAROUND: dealer lat/lon fan-out

Page 0 and page 1 return identical content (activePage=1); the API normalizes 0 → 1 (1-based). Page 101+ returns activePage=101, totalPages=0, items=[]. The backend silently caps at 100 pages regardless of request format (SSR HTML or _rsc=1).

Unfiltered, page 1 reports totalPages=1843. With per-model-series URL paths (/search/{series}), the 5 largest series (macan=846, cayenne=574, 911=185, panamera=108, taycan=105) all exceed 100 pages. Sum accessible across all 12 series = 7,965 items vs claimed 27,735 = 28.7% coverage.

Query-param position param works on the RSC endpoint (confirmed 2026-06-12):

{
"probe": "position_filter",
"passed": true,
"param": "position={postalCode},{lat},{lon},{radius_miles}",
"radius_minus1_means_nationwide": true,
"latlon_required": true,
"zip_only_latlon_0_0_falls_back_to_nationwide": true,
"worst_case_25mi": {"series": "macan", "location": "LA metro (90012)", "totalPages": 66},
"worst_case_50mi": {"series": "macan", "location": "LA metro (90012)", "totalPages": 96},
"sample_results": {
"macan_nationwide": 854,
"macan_25mi_nyc": 63,
"macan_25mi_la": 66,
"macan_25mi_chicago": 34,
"cayenne_25mi_la": 40
}
}

Approved pipeline strategy (dealer lat/lon fan-out, 25mi radius) — superseded 2026-07-12, see below:

  • The dealersearch endpoint returns coordinates.latitude + coordinates.longitude for all 207 dealers — no external geocoding required.
  • Fan out: for each of 207 dealers × 12 series → GET /search/{series}?position={postalCode},{lat},{lon},25
  • With 25mi radius, totalPages stays ≤ 66 (worst case: macan in LA). Never hits the 100-page cap.
  • Deduplicate results by listing id (6-char alphanumeric field). Every listing belongs to a dealer in the 207-dealer set.
  • Total requests: 207 × 12 = 2,484. Coverage: ~100%.

Superseding strategy (dealership query param) — verified by pmcguire 2026-07-12:

The bare /us/en-US/search route (no series path segment) honors a dealership={ppnOrgId} query param that scopes results to exactly that dealer's stock — the returned listings' meta.seller.sellerId equals the requested id. With no model param the response spans all model ranges at once, and condition= filters to one of new/used/porsche_approved/ classic per request (span conditions by calling once per value; listings deduplicate on vin/id). pages.totalPages reports the per-query page count.

  • Fan out: dealer × condition → GET /search?condition={c}&dealership={ppnOrgId}&page={n}
  • A dealer's whole stock is a handful of pages — never approaches the 100-page cap, with no 25mi-radius overlap duplication and no dependency on the hitcounts series enumerator.
  • Note the 2026-06 probes tested seller= (silently ignored) — dealership= is the working param name.

The RSC _rsc=1 format is cleaner (unescaped JSON) and should be used by the pipeline fetcher.

2. Enumerator — mpi_models (modelType)

{
"probe": "enumerator",
"passed": true,
"param": "modelType",
"endpoint": "https://mpi.pccompare.aws.porsche-preview.cloud/markets/PCNA/models/",
"total_records": 82,
"unique_values": 82,
"id_field": "modelType",
"filter_test": "modelRange=911 returns same 82 models (filter ignored)",
"pagination_required": false
}

VERDICT: PASS

The MPI models endpoint returns all 82 PCNA models unconditionally. No authentication, no pagination. Filters (e.g. modelRange=911) are ignored — always returns the complete catalog. All 82 modelType values are unique. This is the authoritative model enumerator.

Model ranges present: 718 (10 models), 911 (many), Cayenne, Macan, Panamera, Taycan.

3. Enumerator — hitcounts (modelSeries — inventory fan-out)

{
"probe": "enumerator",
"passed": true,
"param": "modelSeries",
"endpoint": "https://finder.porsche.com/api/us/en-US/hitcounts",
"total_series": 12,
"series": ["macan", "cayenne", "911", "panamera", "taycan", "718", "356", "944", "918", "928", "914", "968"],
"large_series_exceed_100_pages": ["macan (846)", "cayenne (574)", "911 (185)", "panamera (108)", "taycan (105)"],
"pagination_required": false
}

VERDICT: PASS (enumerator works; pagination cap is a separate gate)

The hitcounts API returns all 12 model series live. No longer drives ingestion (2026-07-12): the dealership-scoped search returns every model range in one query, so no series fan-out — and therefore no hitcounts request — is needed.

4. Enumerator — hitcounts (sellers — dealer fan-out)

{
"probe": "enumerator",
"passed": true,
"param": "seller",
"endpoint": "https://finder.porsche.com/api/us/en-US/hitcounts",
"total_sellers": 207,
"sample_keys": ["13846", "5263", "16927", "2795"],
"pagination_required": false
}

VERDICT: PASS

207 sellers with live inventory returned in a single hitcounts call. These are ppnOrgId values — the same id space as dealersearch ppnOrgId and the search route's dealership= param. Ingestion fans out over the dealersearch dealer list instead (all 217 dealers; a dealer with no stock just returns an empty result).

5. Multi-value filter

N/A — one value per request. The 2026-06 probes found query params silently
ignored on the /search/{series} path variant; verified 2026-07-12 that the bare
/search route DOES honor condition=, dealership=, and model= — each accepting a
single value per request. Spanning conditions means one request per value.

VERDICT: N/A

6. Body shape / bot-block — finder_inventory (RSC)

{
"probe": "body_shape",
"passed": true,
"endpoint": "finder_inventory",
"url": "https://finder.porsche.com/us/en-US/search",
"with_realistic_ua": {"status": 200, "content_type": "text/html", "has_rsc_data": true, "stable_across_3_requests": true},
"without_ua": {"status": 429, "content_type": "text/html", "has_rsc_data": false},
"rsc_format": {"url_param": "_rsc=<random>", "header": "RSC: 1", "content_type": "text/x-component", "data_unescaped": true, "recommended_for_pipeline": true},
"ua_mandatory": true
}

VERDICT: PASS

Stable under 3 consecutive requests with a realistic Chrome UA. Without a UA, the endpoint returns HTTP 429. The _rsc=1 RSC format returns text/x-component with unescaped JSON inline — preferred over SSR HTML (which double-escapes all JSON). Both formats share the same 100-page cap.

Data extraction path (RSC format): lines containing "pages":{"activePage":N,"totalPages":N},"items":[...] are plain JSON inline in the stream.

7. Body shape / bot-block — mpi_models

{
"probe": "body_shape",
"passed": true,
"endpoint": "mpi_models",
"url": "https://mpi.pccompare.aws.porsche-preview.cloud/markets/PCNA/models/",
"with_realistic_ua": {"status": 200, "content_type": "application/json", "models_count": 82, "stable_across_3_requests": true},
"ua_mandatory": false
}

VERDICT: PASS

Pure JSON API, open CORS, stable. No auth required.

8. Body shape / bot-block — finder_hitcounts

{
"probe": "body_shape",
"passed": true,
"endpoint": "finder_hitcounts",
"url": "https://finder.porsche.com/api/us/en-US/hitcounts",
"with_realistic_ua": {"status": 200, "content_type": "application/json", "sellers_count": 207, "stable_across_3_requests": true},
"without_ua": {"status": 429},
"ua_mandatory": true
}

VERDICT: PASS

Stable JSON API. UA required (429 without). Returns full seller + facet counts unconditionally.

9. Body shape / bot-block — dealersearch_dealers

{
"probe": "body_shape",
"passed": true,
"endpoint": "dealersearch_dealers",
"url": "https://www.porsche.com/us/en-US/dealersearch/",
"with_realistic_ua": {"status": 200, "content_type": "text/html", "dealer_count": 217, "stable_across_2_requests": true},
"without_ua": {"status": 200},
"ua_mandatory": false,
"encoding": "HTML-encoded JSON (&quot; = double-quote); Astro SSR prop"
}

VERDICT: PASS

Stable HTML page. UA not required. Dealer data is embedded as HTML-encoded JSON (&quot;) inside the Astro component prop. Each dealer appears twice (434 total occurrences, 217 unique). Fields: ppnOrgId, porschePartnerNo, name, address, facilityType, id (UUID).

Overrides

GateOVERRIDE reasonApproved by
pagination (finder_inventory)Hard server-side cap at 100 pages per query. Approved workaround (supersedes the 2026-06-12 dealer lat/lon 25mi fan-out): the bare /search route honors dealership={ppnOrgId}, scoping each query to one dealer's full multi-range stock — a handful of pages, never near the cap. Pipeline fans out by dealer × condition (new/used/porsche_approved/classic; the route filters one condition per request). Deduplicate by listing id. No hitcounts request needed.pmcguire 2026-07-12
identity_keys (features)The explore stage proposed (modelCode, featureCode) as the feature identity. The correct grain is (listingId, equipmentCode). Equipment option codes (e.g. PU5, 3FU) are per-listing, not per-model-type. Two vehicles with the same modelCode will have overlapping but distinct option sets. The pipeline must associate equipment codes to their listing ID (from the detail page URL slug).pending team approval