# Cross-Country Property-Detail Contract

The frozen data contract for the standardized property-detail experience
(reference: investhink.ai `new-projects/malaysia/vierra-residence`). Every
catalogue consumer — ingestion adapters, merge resolution, export/import,
completeness validation, the shared detail resource and the public detail
page — follows this document. Changing it is a deliberate, reviewable
contract change locked by `tests/Unit/Property/CatalogueContractTest.php`
and `config/project_catalogue.php`.

## Approved owner decisions

### G0 — Market-segment semantics (APPROVED: Option A, user/project owner, 2026-07-13)

- `market_segments` is a MULTI-VALUED set (`new_project`, `subsale`), never a
  single lifecycle enum.
- One canonical development may simultaneously contain `new_project` and
  `subsale` — a development can still sell developer units while its
  completed stock already trades on the secondary market.
- Merge rule: the memberships contributed by ACTIVE sources are combined by
  set union; a membership disappears only when every source carrying it is
  deactivated and the canonical is re-resolved.
- Publication requirements are keyed per membership; a dual-segment record
  must satisfy the union of both blocking lists.

### G9 — Catalogue media portability (APPROVED: Option A, user/project owner, 2026-07-13)

- All catalogue media uses the shared MY GCS bucket.
- Export packages carry a PORTABLE STABLE OBJECT REFERENCE (bucket-relative
  object path). Database media ids and temporary signed URLs are NEVER
  exported — signed URLs are resolved per request at render time and never
  persisted anywhere.
- Destination deployments create their own local `Src\Common\Media` rows
  pointing to the same GCS objects (no binary copy).
- Cloned deployments must NOT physically delete shared source-owned objects;
  they deactivate/detach locally only.

### G10 — Canonical unit identity (APPROVED: mentor, 2026-07-16)

- `catalog_units.uuid` is the provider-neutral canonical identity. `floor` and
  `unit_number` are display labels and are NOT unique within a building.
- Each provider's stable unit id belongs in
  `catalog_unit_sources.external_id`, scoped by its building-source mapping.
  Providers without a stable id use the structured floor/unit fallback.
- A new source identity may reuse a display-equal canonical unit only when
  exactly one candidate exists and that candidate is not already claimed by
  the same building source. Zero or multiple candidates create a distinct
  canonical unit; ingestion never guesses or collapses conflicting units.
- Source attachment follows the same rule, so units with identical display
  labels but distinct source identities remain separate after a merge.

### G11 — Project Detail provider separation (APPROVED: project owner, 2026-08-01)

- Provider selection is by requested fact, not by `market_segments`, canonical
  provider priority or project-specific exceptions.
- PropertySifu is the source of truth for official new-project layouts, launch
  prices, layout media/rent facts and its developer label.
- Financial Projection resolves rent deterministically from the selected
  official layout's imported PropSense `rental_price`, then the live PropSense
  estimate when missing, and only then the 0.35%-of-price fallback. It must not
  reproduce peta-new's request-timing/tab-order race.
- EdgeProp is the source of truth for nearby asking-price/rental comparables
  and market-analysis amenities. `market_airbnbs` owns Airbnb comparables.
- `petav2_legacy` remains provenance/compatibility evidence and is excluded
  from official-unit and current EdgeProp-comparable selection.
- Project Detail always uses the provider-separated analysis path. The former
  `?source_preview=1` mode is retired and must not become a second runtime
  contract.
- Full per-tab query, cache and fallback rules are documented in
  `docs/modules_handbook/main/project-detail/readMe.md`.

## Segment constants

`Src\Analysis\Reference\CatalogProject`:

| Constant | Value | Display |
|---|---|---|
| `SEGMENT_NEW_PROJECT` | `new_project` | New Project / sky |
| `SEGMENT_SUBSALE` | `subsale` | Subsale / amber |

## Publication blocking rules

Configured as `project_catalogue.detail_contract`; evaluated by the
completeness validator per segment membership. Fields not listed never block
publication — they render their documented fallback instead.

- **Every record (`all`)**: `project_name`, `slug`, `country_id`,
  `latitude` + `longitude` (coordinates are REQUIRED — there is no
  address-only fallback; a record without coordinates cannot publish),
  `developer`, non-empty `market_segments`.
- **Records with `new_project` membership**: `sale_status`, `completion`,
  `hero_media`, `floor_plans`. A neutral placeholder hero never satisfies
  publication for a new-project record.
- **Records with `subsale` membership**: `market_price`.
- ⚠️ **Waivers by market (`waived_by_country`)** — a fourth key in
  `project_catalogue.detail_contract` that the validator SUBTRACTS from the
  requirement set. **HK waives `floor_plans` and `completion`; AE waives
  `floor_plans`, `completion` and `market_price`.** A waived requirement is
  removed entirely, not softened — so a Hong Kong record publishes with no floor
  plans at all, and the lists above are the MALAYSIAN requirement set, not a
  universal one.

⚠️ **`developer` is not the column.** The validator never reads
`catalog_projects.developer`; it resolves the requirement through the
`catalog_project_developers` PIVOT to the `developers` entity table. A record
carrying a developer NAME with no pivot row is INCOMPLETE and cannot publish —
which is invisible on screen, because the page renders that name perfectly well.

Virtual requirement keys the validator interprets:

| Key | Satisfied when |
|---|---|
| `completion` | `completion_year` OR `completion_date` present |
| `hero_media` | at least one ACTIVE hero media row — either representation (scraped URL or uploaded Media) counts |
| `floor_plans` | at least one catalogue floor plan exists |
| `market_price` | `psf_median` OR `price_median` present |

## Field matrix

Ownership: P=project, FP=floor-plan, M=media, AI=generated content,
W=workflow/deployment-local. Req: BLOCK-all / BLOCK-new / BLOCK-sub per the
blocking rules above; opt = optional with the stated display fallback.
Currency, locale and units come from the `countries` row at render — never
baked into stored data.

| Section / field | Own | Type/unit | Req | Fallback when legitimately absent |
|---|---|---|---|---|
| project_name | P | string | BLOCK-all | — |
| name_translations | P | json {locale: name}, merged PER LOCALE | opt | show project_name |
| slug (URL identity) | P | string, unique per country (CI) | BLOCK-all | — |
| market_segments | P | json set | BLOCK-all (non-empty) | — |
| sale_status | P | string | BLOCK-new | subsale-only records: hidden row |
| address / area / state / postal | P | strings | opt | hide missing rows |
| latitude / longitude | P | decimal | BLOCK-all | — (no address-only fallback) |
| developer (+brand) | P | string | BLOCK-all | — |
| developer track record | P (derived) | read-time query | opt | hide section |
| hero image | M | scraped URL or uploaded Media | BLOCK-new | subsale-only: neutral placeholder allowed |
| gallery / brochure / price-list docs | M | ordered media rows | opt | hide gallery/doc links |
| tenure | P | string | opt | hide |
| property_type | P | string | opt | hide |
| completion_year / completion_date | P | year + date | BLOCK-new (one of) | subsale: "TBC" |
| unit count (non_landed_units) | P | int | opt | hide |
| price range (price_min / price_max) | P | bigint, canonical currency | opt | "Price on ask" |
| overview / description | P | text (factual) | opt | AI summary or hide |
| facilities | P | json | opt | hide |
| phases (parent_catalog_project_id) | P | explicit admin-set link | opt | flat list |
| parking_info / management_fee | P | string | opt | hide |
| floor plans | FP | name, sqft, bed, bath, price, rent stats | BLOCK-new (at least 1) | subsale: empty-state |
| floor-plan car_parks | FP | tinyint | opt | hide |
| floor-plan images | M | media rows per plan | opt | text-only plan card |
| sale_process | P | json (provider-structured) | opt | hide section |
| financing inputs | W | render-time calc | opt | defaults |
| investment / rent / PSF / market value | P/FP | existing stats columns | BLOCK-sub (market_price) | "insufficient data" blocks |
| amenities / transit / schools / nearby | P | json | opt | hide blocks |
| AI summaries + freshness metadata | AI | versioned rows, 4 locales | opt | hide with "generating" state |
| provenance / freshness | P/src | sources + run watermarks | BLOCK-all (at least 1 active source or manual record) | — |
| publication state | W | published_at + validator | BLOCK-all | unpublished = 404 public |

## Content locales and preset routing

Launch AI content locales (`project_catalogue.ai_locales`):
`en`, `zh-Hans`, `yue-Hant-HK`, `zh-Hant-TW`.

Public preset routing (`project_catalogue.locale_presets`):

| Preset | Content locale |
|---|---|
| my-cn | zh-Hans |
| my-en | en |
| sg | en |
| cn | zh-Hans |
| hk | yue-Hant-HK |
| tw | zh-Hant-TW |

Fallback chain: preset content locale, then `en`, then hidden with a flag.
Rules for generated prose: factual inputs only; amounts stay in the
canonical project currency verbatim; NO converted prices inside prose —
formatting/conversion happens at render from the `countries` row.

**Scope**: these locales cover CATALOGUE CONTENT only (AI prose and
`name_translations`). UI chrome catalogs are unchanged this milestone — the
`hk`/`tw` presets keep rendering the `zh_CN` UI catalog until a dedicated UI
translation task delivers `yue`/`zh-Hant` catalogs.

## Representation decisions

- Multi-phase developments: one canonical row per phase plus an explicit
  `parent_catalog_project_id` set by an admin (validated: same country, no
  self-parent, no cycles, flat single level) — never assigned automatically.
- Media: one `catalog_media` table with two representations — scraped rows
  (external `url` + stable identity `(catalog_project_source_id,
  source_key)`) and uploaded rows (`media_id` referencing `Src\Common\Media`;
  `url` NULL). Exactly one of `url` / `media_id` per row.
- Ranges are explicit `price_min` / `price_max` columns; medians are never
  overloaded to carry ranges.
- Developer stays a string; the track record is a read-time query over
  canonical rows sharing the developer (no entity table until a real source
  requires one).
- Publication (`published_at`) is deployment-local and never travels in
  export packages.
- Member-gated sections travel as a per-section `locked` map
  (`units`, `financing`, `analysis`, `projection`, `supply`), each true for a
  guest. The gate is enforced by **omitting the payload server-side** — a guest
  never receives `floor_plans` — so the lock is not something a client can undo.
  `Components/ProjectDetail/LockedSection.vue` renders in its place. One shared
  page for guests and members; never a duplicated public/member pair.
- Factual provenance (source rows, raw payloads, watermarks) is strictly
  separate from generated prose (`catalog_ai_contents` referencing
  `ai_requests`).
