# Project Catalogue — START HERE

**The single entry point for the project catalogue and everything around it.** Catalogue
knowledge is spread across ~30 markdown files in four different places. This file is the map:
read it first, then follow it to the two or three files your task actually needs.

---

## How to use this file (instructions for whoever reads it — human or AI)

You have been pointed at this file because the task touches the project catalogue. Work in
this order:

1. **Read §1 (the 60-second model) in full.** It is short and it prevents the most common
   wrong assumptions.
2. **Read §2 (the traps).** Every item there has already cost someone time. Several are
   invisible in normal development and only appear in one configuration.
3. **Find your task in §3 (the routing table) and open the files it names.** Open them —
   do not answer from this file alone. This file is a map, not the territory; it deliberately
   omits detail so it stays short enough to read every time.
4. **When §3 has no row for your task**, use §4 (the full inventory) and pick by topic.
5. **Then open the actual code the doc names, and read it, before you conclude anything.**
   See the rule below — it is the most important instruction on this page.

### ⚠️ Never answer from the docs alone — always read the code they point at

**These files are a map, not the territory, and parts of the map are out of date.** They are
maintained by hand, and by AI sessions that were in the middle of something else: more than
once, behaviour was changed and the markdown was not updated in the same change. §5 lists the
places already known to be wrong — but by definition it cannot list the ones nobody has noticed
yet, and *your* task may be standing on one of them.

So, every time:

- Use a doc to learn **where to look and why** something is the way it is. That is what
  documentation is good at, and the code cannot tell you the *why*.
- Use the **code** to learn **what it does now**. Before you state how something behaves,
  change it, or plan around it, open the file the doc named — the model, the service, the
  command, the migration, the config — and read the relevant part.
- When a doc and the code disagree, **the code wins.** Then fix the doc in the same change
  (see the maintenance rule below) and, if it is a trap others will hit, add it to §2 or §5.
- §6 lists what to read when the docs run out. Reach for it freely; the doc-blocks in this
  module are unusually detailed and explain the reasoning behind most decisions.

A confident answer built only on these files is how a stale line becomes a bug.

### Maintenance rule

**If you change the catalogue or any of its sub-modules, update the markdown in the same
change** — correct the affected file, and if the change has no home in the current set, write a
new `.md` beside the others in this folder (a self-contained sub-module gets its own folder,
like `vr360/`). **Then add a line for it in §4, and a row in §3 if a task should route to it.**
A doc this file does not list is a doc nobody will find.

---

## 1. The 60-second model

The catalogue is **the canonical project database**: one `catalog_projects` row per
real-world development, carrying a stable `uuid` (identity across deployments) and a
per-country `slug` (public URLs). Around it hang floor plans, media, versioned AI prose, and
the provider **source records** that merge deterministically into it.

Four things that are true and routinely assumed otherwise:

- **The catalogue may live in a different database.** It is reached over the `catalogue`
  connection, which resolves either to this deployment's own database or to the shared
  `master_projects` — a deployment setting, not a code change. Production keeps a copy; a
  developer box may read the master live.
- **Almost nothing writes the catalogue directly.** Providers land in an external scraped
  database, adapters turn those rows into *source records*, and a merge service resolves the
  canonical row. Admin edits are field-whitelisted overrides that survive re-ingestion.
  ⚠️ The one exception is **`POST /{country}/projects/{slug}/uae-analyze`, which is PUBLIC**:
  on a snapshot miss it calls the Estate UAE API and persists each payload as
  `catalog_project_sources` rows **on the catalogue connection**. It writes SOURCE rows only,
  never the canonical, so the merge story still holds — and it is a no-op unless
  `services.estate_uae.key` is set.
- **Working projects reference the catalogue and never override it.** A site table points at
  it with a pair of columns — an integer id for local joins and a uuid for identity.
- **Identity is the uuid.** Integer ids are local and can be rebuilt; anything that travels
  between deployments keys on uuid.

---

## 2. Traps — read before touching anything

| Trap | Why it bites |
|---|---|
| **A bare `DB::table('catalog_…')` does NOT reach the catalogue.** | It reads the DEFAULT database. Identical while the two collapse onto one database, silently EMPTY when a box reads the master live. Always `DB::connection('catalogue')`. Found this way in `BuildHkUnitSizeBand` on 2026-09-11 — the HK size band rendered blank and nothing errored. |
| **A catalogue-family schema change goes in `database/migrations/catalogue/`, never `database/migrations/` — but the NAME is not the test, the CONNECTION is.** | One file there is applied to TWO databases, and `grep database/migrations/*.php` does not even see the directory. A table is catalogue-family only if the MASTER owns it — i.e. its model pins `protected $connection = CatalogProject::CONNECTION`. About half the `catalog_*` / `market_*` tables pin nothing and are petav3-owned SITE tables (`catalog_vr_bakes`, `catalog_analysis_snapshots`, `catalog_publish_reviews` / `catalog_review_*`, `catalog_project_highlights`, `catalog_floor_plan_key_sizes`, `catalog_legacy_crosswalks`, `market_sites` / `market_site_countries`); their migrations stay in `database/migrations/`. Namespace is no guide either — `CatalogVrBake` sits in `Src\Analysis\Reference` beside the pinned models. File one wrongly and the Hub's `migrate --database=catalogue` creates a site table on the shared `master_projects`, or fatals its production migrate. List: [databases-and-distribution.md](/docs/modules_handbook/shared/project-catalogue/databases-and-distribution.md) §3. |
| **`CATALOGUE_USE_DEFAULT_CONNECTION` is turned off by DELETING the line, never `=false`.** | `env()` in this project is **CakePHP's**, not Laravel's — it returns the raw string, and `"false"` is truthy. The same trap applies to every `env('X', false) ? … : …` in the codebase. |
| **Never JOIN across the catalogue and default connections.** | Works while both resolve to one database, fatals when they do not. Pluck keys from one side, `whereIn` on the other. |
| **`whereHas('catalogProject')` is one of those joins, and it does NOT fatal — it reads the WRONG database silently.** | Laravel merges the EXISTS into the **parent's** SQL, so it runs on the SITE connection and matches the site's own `catalog_projects` — a frozen mirror, or nothing — never the master. Replacement: `CatalogueFederationService::matchingReferenceIds($ids, $constrain)`, which asks each owning database and hands back a plain id list (§6 of [databases-and-distribution.md](/docs/modules_handbook/shared/project-catalogue/databases-and-distribution.md)). **Invisible on a collapsed box and to the whole suite** (phpunit forces the collapse), so only a box reading the live master ever finds one. |
| **Never chain a federated relation's builder: `$model->catalogProject()->firstOrFail()` / `->get()` / `->exists()`.** | `Relation::__call` forwards straight to the PRIMARY builder, bypassing `FederatedBelongsTo::getResults()` — so a project this platform created itself resolves to nothing and the caller 404s. Use `$model->catalogProject` or `->catalogProject()->getResults()`. This is what broke `ProjectRepository::syncFromCatalog` for every platform-created row. |
| **The public detail pages now honour `site_catalog_projects`, and NOTHING writes that table yet.** | MY/HK/UAE all ask `CatalogueDetailService::viewerMaySee()` (published **and** listed on this site; `view-projects` previews past both). `SiteCatalogProjectRepository` still has no route and no controller, so a deployment switched to `Site::MODE_EXPLICIT` would 404 **every** project page until something writes its selections. petav3's own site row is seeded `MODE_INHERIT` with no suppressions, so nothing changes here today — but standing up a white-label means building that screen FIRST. |
| **Do NOT drop `properties`, `property_details`, `property_floor_plans`, `property_layout_types`, `floor_plan_analysis`, `fields_address`, `airbnbs`, `property_agents`.** | They are retired and empty, but a fresh `migrate` still creates them and the drop gate has not passed. |
| **`property_analyses`, `saved_layouts`, `layout_analyses` are LIVE tables**, despite reading like legacy names. | Deleting them deletes member data. |
| **`market_sites` and `sites` are unrelated.** | One maps a hostname to countries; the other is a whole deployment. The names are the only thing they share. |
| **Catalogue writes need a writable catalogue.** | On a box reading the master with a SELECT-only account, every create / edit / merge / publish-review fails with `1142 … command denied`. That is correct, not a bug. ⚠️ **It is not only admin paths.** `ProjectDetailAe.vue` fires the public `uae-analyze` POST from `onMounted` for **every visitor, guests included**, so on a snapshot miss anonymous traffic writes the catalogue — a 1142 on a SELECT-only master, and on a WRITABLE master one deployment's page views writing the catalogue every deployment reads. To seed those snapshots safely: run `catalogue:harvest-uae-details` **with** `CATALOGUE_USE_DEFAULT_CONNECTION` set (it harvests into the local database — it writes through the same store, so it 1142s otherwise), then remove that line and run `catalogue:copy-uae-snapshots`, which pushes them up and hard-refuses while the catalogue still resolves locally. |
| **A 403 on every catalogue edit is the DOMAIN gate, not permissions.** | `CATALOGUE_EDIT_DOMAINS` defaults to `propertylabglobal.com`, so a fresh checkout on `petav3.test` gets 403 on every catalogue write and the edit buttons hide. Set `CATALOGUE_EDIT_DOMAINS=*` in `.env`. The gate is NOT disabled by single-database mode — see the module doc's *Who may edit the master*. |

---

## 3. Routing table — find your task, open those files

| If the task is… | Read |
|---|---|
| **Which database / connection does X read?** Setting up a dev box. Anything naming `catalogue`, `master_projects`, `MASTER_DB_*` | [databases-and-distribution.md](/docs/modules_handbook/shared/project-catalogue/databases-and-distribution.md) §1–§2, §7 |
| **Writing a migration that touches `catalog_*`, `developers`, `market_*`** | [databases-and-distribution.md](/docs/modules_handbook/shared/project-catalogue/databases-and-distribution.md) **§6.1** — non-negotiable. **Read its last paragraph first**: several `catalog_*` / `market_*` tables are site-owned and their migrations stay in `database/migrations/` (§3 lists them) |
| **Moving a catalogue between deployments** (export/import, mirror, cutover, drift) | [databases-and-distribution.md](/docs/modules_handbook/shared/project-catalogue/databases-and-distribution.md) §5 |
| **A field merges wrong / provider priority / classification vocabulary / overrides** | [readMe.md](/docs/modules_handbook/shared/project-catalogue/readMe.md) *How it works* (Merge, Classification) + `config/project_catalogue.php` |
| **A project is missing from a list, or a count is doubled** | [readMe.md](/docs/modules_handbook/shared/project-catalogue/readMe.md) *Federated reads* + `CatalogueFederationService` |
| **Publishing / unpublishing / completeness / the review queue** | [readMe.md](/docs/modules_handbook/shared/project-catalogue/readMe.md) *publication* + [property-detail-contract.md](/docs/property-detail-contract.md) |
| **Who may edit a catalogue record** | [readMe.md](/docs/modules_handbook/shared/project-catalogue/readMe.md) *Who may edit the master* + `config/site.php` |
| **Adding a scraper / provider, or "where does scraped data come from?"** | [neighbours-and-legacy.md](/docs/modules_handbook/shared/project-catalogue/neighbours-and-legacy.md) §5 + [readMe.md](/docs/modules_handbook/shared/project-catalogue/readMe.md) *Merge* |
| **A CRM table needs to point at a project or floor plan** | [neighbours-and-legacy.md](/docs/modules_handbook/shared/project-catalogue/neighbours-and-legacy.md) §1 |
| **A site table needs to SEARCH, FILTER, SORT or COUNT by its catalogue project** (and `whereHas` is what you were about to write) | [databases-and-distribution.md](/docs/modules_handbook/shared/project-catalogue/databases-and-distribution.md) §6 → *Crossing the boundary without a subquery* — `CatalogueFederationService::siteOnlyReferenceIds` / `referencedRows` / `matchingReferenceIds` / `isListedOnSite`, plus `Src\Common\Relations\FederatedHasMany` for a hasMany. Worked examples in [readMe.md](/docs/modules_handbook/shared/project-catalogue/readMe.md) *Federated reads* |
| **Deleting catalogue media, or a media row that outlived its file** | [readMe.md](/docs/modules_handbook/shared/project-catalogue/readMe.md) *admin media* (`CatalogMediaRepository`) + [shared/media/readMe.md](/docs/modules_handbook/shared/media/readMe.md) — the cleanup retry only searches the SITE `media` table, so a catalogue-schema row has to be dropped by the repository |
| **Ids look wrong after a re-seed / cutover** | [neighbours-and-legacy.md](/docs/modules_handbook/shared/project-catalogue/neighbours-and-legacy.md) §1 (`catalogue:repair-refs`) + [databases-and-distribution.md](/docs/modules_handbook/shared/project-catalogue/databases-and-distribution.md) §4 |
| **Deleting old `property_*` / `airbnbs` / `fields_address` tables** | [neighbours-and-legacy.md](/docs/modules_handbook/shared/project-catalogue/neighbours-and-legacy.md) §2 — **the answer is "not yet"** |
| **Analysis numbers are stale, missing, or slow** | [neighbours-and-legacy.md](/docs/modules_handbook/shared/project-catalogue/neighbours-and-legacy.md) §3 + [main/analyze-property/readMe.md](/docs/modules_handbook/main/analyze-property/readMe.md) |
| **White-label: which deployment lists which projects** | [readMe.md](/docs/modules_handbook/shared/project-catalogue/readMe.md) *Site listing layer* + [neighbours-and-legacy.md](/docs/modules_handbook/shared/project-catalogue/neighbours-and-legacy.md) §4 |
| **Which hostname may show which countries** | [manage/markets/readMe.md](/docs/modules_handbook/manage/markets/readMe.md) + [neighbours-and-legacy.md](/docs/modules_handbook/shared/project-catalogue/neighbours-and-legacy.md) §4 |
| **The public project page** (tabs, data contract, caching) — the doc below is **MY only** | [main/project-detail/readMe.md](/docs/modules_handbook/main/project-detail/readMe.md) + [property-detail-contract.md](/docs/property-detail-contract.md). ⚠️ `/{country}/projects/{slug}` branches by country in `ProjectDetailController::show()` into THREE separate Inertia pages that share only the URL: `Main/Site/ProjectDetail` (MY, via `CatalogueDetailService`), `Main/Site/ProjectDetailHk` (HK, `BuildHkProjectDetail`), `Main/Site/ProjectDetailAe` (UAE, `BuildAeProjectDetail`). Those two docs describe MY only. For **HK** read [hk-data-requirements.md](/docs/hk-data-requirements.md) + [hk-catalogue-mapping.md](/docs/hk-catalogue-mapping.md); for **UAE**, [dubai-projects-rollout-plan.md](/docs/planning/dubai-projects-rollout-plan.md) |
| **Reusing the complete Malaysian project page inside a drawer** | [shared/project-detail/readMe.md](/docs/modules_handbook/shared/project-detail/readMe.md) — canonical prop builder, shared tabs, isolated URL state, cancellation and country adapters; [area-guide-map/readMe.md](/docs/modules_handbook/shared/project-catalogue/area-guide-map/readMe.md) — caller access checks and UUID/slug collision guard |
| **360° aerial panoramas** | [vr360/readMe.md](/docs/modules_handbook/shared/project-catalogue/vr360/readMe.md) + [production-setup/vr-bake-worker.md](/docs/modules_handbook/production-setup/vr-bake-worker.md) |
| **"Is there a command for this?"** / running any `catalogue:*`, `market:*`, `developers:*` command | [commands.md](/docs/modules_handbook/shared/project-catalogue/commands.md) — all 51, grouped by purpose |
| **Launching a new country** (HK, UAE, MY) | §4 *Country rollouts* below — start with the one for that country |
| **The appointment flow touching projects/floor plans** | [neighbours-and-legacy.md](/docs/modules_handbook/shared/project-catalogue/neighbours-and-legacy.md) §1 (`projects.catalog_project_id`, `bookings.catalog_floor_plan_id`) + [manage/engagement/booking.md](/docs/modules_handbook/manage/engagement/booking.md). ⚠️ The appointment engine itself carries **no** catalogue key — it reaches the catalogue only through the CRM `projects` row. Do not go looking in the appointment-engine docs: "catalogue" there means `NodeCatalogue`, the workflow node-type registry |
| **Working projects / bookings / sales surfaces** | [manage/engagement/sales-projects.md](/docs/modules_handbook/manage/engagement/sales-projects.md) |
| **Shared Area Guide country/region/area content or cross-site registry updates** | [area-guide-registry/readMe.md](/docs/modules_handbook/shared/project-catalogue/area-guide-registry/readMe.md) — registry/editor, local development and production cutover; [area-guide-shared-content-plan.md](/docs/modules_handbook/shared/project-catalogue/area-guide-shared-content-plan.md) — decisions; full [acceptance checklist](/docs/modules_handbook/main/area-guide/catalogue-map-requirements.md) |
| **Running the Area Guide LOCALLY, or wondering why its media is blank on a dev machine** | [area-guide-registry/readMe.md](/docs/modules_handbook/shared/project-catalogue/area-guide-registry/readMe.md#working-on-the-area-guide-locally-2026-09-18) — the two local modes (read production vs edit locally), the four switches, the five migrations, pulling live content down; [area-guide-map/readMe.md](/docs/modules_handbook/shared/project-catalogue/area-guide-map/readMe.md#getting-productions-media-onto-a-development-machine-2026-09-18) — the GCS buckets, the server-side prefix copy, and why a wrong bucket fails SILENTLY |
| **Continuing the Area Guide enhancement in another session** | [area-guide-map/handover.md](/docs/modules_handbook/shared/project-catalogue/area-guide-map/handover.md) — discussion, confirmed decisions, local completion, evidence, remaining inputs and next-session brief |
| **Published map pins, uploaded building models, private panoramas, click-placed markers (a project, a custom building or a location VIDEO) or the standalone panorama/video pages** | [area-guide-map/readMe.md](/docs/modules_handbook/shared/project-catalogue/area-guide-map/readMe.md) — implemented map/assets, permissions, renderer, image revisions, the shared link rule (`AreaGuideLinks`) and deployment runbook; [Media](/docs/modules_handbook/shared/media/readMe.md) — explicit shared storage/outbox ownership |
| **Refreshing a developer machine's database** | `docs/operations/local-database-sync.md` — **local-only, not in git**; if it is absent on your checkout, that is expected |

---

## 4. Full inventory

### The module (this folder)

| File | Lines | What it holds |
|---|---|---|
| [readMe.md](/docs/modules_handbook/shared/project-catalogue/readMe.md) | ~1,345 | **The module doc.** Merge, classification vocabulary, completeness, publication, admin overrides, federated reads, the site listing layer, the edit-domain gate, reference usage, related files. One long `How it works` section — use your editor's search, not scrolling |
| [databases-and-distribution.md](/docs/modules_handbook/shared/project-catalogue/databases-and-distribution.md) | ~450 | Connections, live-vs-copy modes, what lives where, id spaces, the two distribution generations, the rules that are not cosmetic (**§6.2 — crossing the boundary without a subquery**), migrations, dev-box setup |
| [neighbours-and-legacy.md](/docs/modules_handbook/shared/project-catalogue/neighbours-and-legacy.md) | ~400 | Site tables that point at the catalogue, the eight retired Analyze tables, the analysis precompute chain, the two "site" families, the scraped-data connection, documentation debt |
| [commands.md](/docs/modules_handbook/shared/project-catalogue/commands.md) | ~160 | **All 51 catalogue-family artisan commands**, grouped by purpose, with the warnings that belong beside them |
| [vr360/readMe.md](/docs/modules_handbook/shared/project-catalogue/vr360/readMe.md) | 142 | Sub-module — 360° aerial panoramas, the bake pipeline, alignment, failure modes |
| [area-guide-shared-content-plan.md](/docs/modules_handbook/shared/project-catalogue/area-guide-shared-content-plan.md) | evolving | Current-site editing against one live master: shared content decisions, implementation status and production cutover boundaries |
| [area-guide-registry/readMe.md](/docs/modules_handbook/shared/project-catalogue/area-guide-registry/readMe.md) | ~400 | Country/region/area registry (**LIVE on production 2026-09-18**), Manage editor, source switch, the two LOCAL modes and how to pull production content down, `profile.video` / `video_media` shapes, import, permissions, revisions, consumer contract, the completed cutover runbook and verification limits |
| [area-guide-map/readMe.md](/docs/modules_handbook/shared/project-catalogue/area-guide-map/readMe.md) | ~350 | Published federated project pins, shared asset editor, GLB placement, administrator-only panorama polygons, private media, upload/cleanup ownership, drawer collision guard and production runbook |
| [area-guide-map/validation.md](/docs/modules_handbook/shared/project-catalogue/area-guide-map/validation.md) | ~120 | Local requirement acceptance, actual browser journeys, automated checks and unverified production/representative-asset boundaries |
| [area-guide-map/handover.md](/docs/modules_handbook/shared/project-catalogue/area-guide-map/handover.md) | snapshot | September 14 session handover: discussion and confirmed scope, document reading map, completed local work, environment, verification limits and remaining milestones |

### Handbook docs that consume the catalogue

| File | What it covers |
|---|---|
| [manage/appointment-engine/](/docs/modules_handbook/manage/appointment-engine/) | **Indirect only — the engine stores no `catalog_*` key.** Since the 2026-09-09 merge its project IS the CRM `projects` row, so the catalogue link is `projects.catalog_project_id`, owned by the CRM. Its only catalogue touch is eager-loading `project.catalogProject`. ⚠️ "catalogue" throughout those docs means `NodeCatalogue`, the workflow node-type registry — not this module |
| [main/analyze-property/readMe.md](/docs/modules_handbook/main/analyze-property/readMe.md) | The member-facing analysis screens — Layout Analysis, the `saved_layouts` shortlist |
| [main/project-detail/readMe.md](/docs/modules_handbook/main/project-detail/readMe.md) | The public project page and its tab data contract — **the MY page only**; HK and AE are separate Inertia pages off the same URL (see §3) |
| [shared/project-detail/readMe.md](/docs/modules_handbook/shared/project-detail/readMe.md) | Reusable Malaysian detail content for the standalone page and Area Guide drawer: canonical props, full tabs, URL/request isolation and adapter boundaries |
| [manage/markets/readMe.md](/docs/modules_handbook/manage/markets/readMe.md) | `market_sites` — Setting → Markets, hostname → countries |
| [manage/engagement/sales-projects.md](/docs/modules_handbook/manage/engagement/sales-projects.md) | Working projects and the sales surfaces built on them |
| [manage/engagement/{booking,referrals,closing-modes,imports,perimeter,retired}.md](/docs/modules_handbook/manage/engagement/) | Engagement surfaces carrying catalogue keys |
| [manage/meta-ads/marketing/readMe.md](/docs/modules_handbook/manage/meta-ads/marketing/readMe.md) | Ads mapped to projects |
| [main/rental-estimate/readMe.md](/docs/modules_handbook/main/rental-estimate/readMe.md) | Public rental estimate, keyed to project + floor plan |
| [production-setup/vr-bake-worker.md](/docs/modules_handbook/production-setup/vr-bake-worker.md) | The VR bake box runbook |
| [manage/area-tutorials/readMe.md](/docs/modules_handbook/manage/area-tutorials/readMe.md) | Tutorial stops keyed on `catalog_project_id` — a stop never stores a price, the catalogue is read at render time. The project link gained its `catalog_project_uuid` twin on 2026-09-14 and is now repairable; ⚠️ the same table's **landmark** link (`market_catalyst_id`) is still integer-only and unrepairable (see neighbours §1) |
| [main/area-guide/walkable-session/readMe.md](/docs/modules_handbook/main/area-guide/walkable-session/readMe.md) | The walkable game's stations ARE `catalog_projects` rows — nothing is authored |
| [main/dmaic-road/readMe.md](/docs/modules_handbook/main/dmaic-road/readMe.md) | The road's New Project strip reads `catalog_projects` via `BuildNewProjectListing` |

### Outside the handbook — still authoritative

| File | Why it matters |
|---|---|
| [docs/property-detail-contract.md](/docs/property-detail-contract.md) | **The contract** every country's detail page must satisfy. Cited by the module doc |
| [database/migrations/catalogue/README.md](/database/migrations/catalogue/README.md) | **The migration rule**, in the directory it governs. Short and binding |
| [catalogue-packages/README.md](/catalogue-packages/README.md) | The JSON transfer packages — layout and versioning |
| [docs/propertysifu-my-migration-runbook.md](/docs/propertysifu-my-migration-runbook.md) | The original "do not delete the legacy tables" warning, plus the MY migration steps |
| [docs/DEPLOY.md](/docs/DEPLOY.md) | Deploy, including catalogue database setup |

### Country rollouts

| File | Country |
|---|---|
| [docs/planning/dubai-projects-rollout-plan.md](/docs/planning/dubai-projects-rollout-plan.md) | **UAE** — the largest of these (615 lines) |
| [docs/petahk-project-catalogue-handoff.md](/docs/petahk-project-catalogue-handoff.md) | **HK** — the PetaHK handoff |
| [docs/hk-catalogue-mapping.md](/docs/hk-catalogue-mapping.md) | **HK** — field mapping |
| [docs/hk-data-requirements.md](/docs/hk-data-requirements.md) | **HK** — what the detail page needs |

---

## 5. Known stale spots — do not trust these without checking the code

- **`docs/planning/master-catalogue-shared-database-plan.md` does not exist and never did.**
  Sixteen files cite it, including `config/database.php` and seven migrations. Its content is
  now [databases-and-distribution.md](/docs/modules_handbook/shared/project-catalogue/databases-and-distribution.md).
  When you meet that path in a comment, read the new file instead.
- **`docs/hk-handoff.md` is a SECOND phantom document, and it is cited from live code.**
  `BuildAeCommunityAnalysis` and `PropertyFinderUaeAdapter` both name it as the source of the
  "verbatim-port rules" that shaped their mapping, and the Dubai rollout plan cites it twice.
  It does not exist. ⚠️ It is **not** `docs/petahk-project-catalogue-handoff.md` — that is a
  different document about the catalogue/working-project split, with nothing about verbatim
  porting. So the reasoning behind the UAE field mapping is currently unrecorded anywhere.
- **The `reference` connection comment in `config/database.php` is stale.** It names
  `edgeprop_projects`, `property_agents` and `airbnbs`; the live surface is the `ih_*` family.
  See [neighbours-and-legacy.md](/docs/modules_handbook/shared/project-catalogue/neighbours-and-legacy.md) §5.
- **All 51 catalogue-family commands are now listed in
  [commands.md](/docs/modules_handbook/shared/project-catalogue/commands.md).** ⚠️ Do not use
  `php artisan list catalogue` as the index — it filters by namespace and misses
  `catalog:estimate-key-sizes`, `market:*`, `developers:*`, `layouts:project`,
  `edgeprop:import` and `petav2:*`. The class doc-blocks remain the deepest documentation and
  are unusually good; read the one for anything destructive before running it.
- **The module doc's `Related files` section is not exhaustive.** It predates several of the
  services and commands it should list.
- **Remediation checkpoint — 2026-09-14.** A second audit + fix run went through the catalogue's
  federation seams and the Area Guide work that sits on them. What CHANGED in code and is now
  described in these docs: the `whereHas` bug below (fixed, and its description corrected); the
  detail pages of all three countries now enforce the site listing, not publication alone;
  `Project::floorPlans()` / `floorPlanCounts()` / `matchingCanonicalIds()` /
  `scopeOrderByCanonicalName()` are federated; `area_tutorial_stops` gained its uuid twin;
  `CatalogMediaRepository` no longer strands a catalogue-schema `media` row behind a failed
  object delete. What is still OPEN is listed as open below and in §2 — nothing here was marked
  done on the strength of a report alone; every statement was checked against the file it
  describes. ⚠️ **Not verified:** anything requiring a browser, production, a real bucket asset
  or a physical device. The evidence behind these updates is automated tests and code reading.
- **This doc set was audited against the code on 2026-09-12**, in two passes — 19 dimensions,
  every finding verified adversarially before anything was rewritten, 88 confirmed problems
  fixed. Coverage is now even across all 19. What was NOT audited: `readMe.md`'s middle third
  in equal depth, and everything outside `docs/modules_handbook/shared/project-catalogue/`.
- ✅ **The `whereHas` bug described here is FIXED (2026-09-14), and the old wording of it was
  backwards.** It never "queried the catalogue connection alone": Laravel's `addHasWhere()`
  merges the EXISTS into the **parent's** SQL, so it ran on the SITE connection against the
  site's own `catalog_projects` — a frozen mirror, or nothing. §6 of
  [databases-and-distribution.md](/docs/modules_handbook/shared/project-catalogue/databases-and-distribution.md)
  always described it correctly; this file was the inverse. Both live callers now resolve
  per-database instead:
  - `ProjectDetailController::approvedVrPanorama()` (the public 360° tab) takes the page's
    already-resolved federated project and filters `catalog_vr_bakes` by `catalog_project_id`
    **plus** `(catalog_project_uuid IS NULL OR = the project's uuid)`, in a new private
    `approvedVrBake()`. The uuid half stops a stale integer after a master re-key putting
    another building's panorama on the page; the `IS NULL` half keeps every bake written before
    the uuid backfill visible.
  - `App\Http\Requests\Manage\RentalEstimate\SubmissionQueryRequest::filterSearch` (⚠️ **the
    full namespace matters** — there is an unrelated `Manage\PropertyMatch\SubmissionQueryRequest`)
    plucks the project ids its submissions actually reference and passes them to
    `CatalogueFederationService::matchingReferenceIds()`, then applies a plain `orWhereIn`.

  The RULE stands and is now in §2: never `whereHas` a `FederatedBelongsTo`, and never chain
  `->catalogProject()->firstOrFail()`. ⚠️ The rule applies only to a SITE model using
  `BelongsToCatalogue`. Four remaining `whereHas('catalogProject')` callers are **correct** and
  must not be "fixed": `BuildHkDeveloperRecord`, `BuildHkSupplyComparison`,
  `StoreHouse730CatalogueMedia` and `StoreUaeCatalogueMedia` all sit on `CatalogProjectSource` /
  `CatalogMedia`, catalogue-family models that inherit their parent's connection through
  `KeepsRelatedConnections`, so their EXISTS is same-connection.
- ⚠️ **Two real CODE bugs were found and are still NOT fixed — they are described here only.**
  - **`flg_owner_listing_rows.catalog_floor_plan_uuid` is never written** — both linking jobs
    use the query builder, which bypasses `TracksCatalogueUuid`. Worse than a gap: running
    `catalogue:repair-refs --apply` would write a STALE uuid's plan id back over a correct
    link. **Do not run `--apply` against that table** until someone audits how many rows carry
    a uuid that no longer matches their id.
  - **Sync deletes `catalog_media` as a mass delete, firing no model events**, so the GCS
    object and the `media` row behind `media_id` are orphaned. The admin path does not have
    this problem — it goes through `MediaService::delete()`.
- ⚠️ **Two gaps opened by the 2026-09-14 work are deliberately still open, with reasons:**
  - **`area_tutorial_stops.market_catalyst_id`** — the LANDMARK half of a tutorial stop is still
    integer-only, master-only in both its pickers, and absent from the repair map, while the
    PROJECT half beside it was fixed. `market_catalysts` is empty in the live catalogue, so the
    impact is nil today; closing it needs a migration plus a third entry in the shared
    `TracksCatalogueUuid` column map, which is booted on all twelve federated site tables and
    wants its own review. See [neighbours-and-legacy.md](/docs/modules_handbook/shared/project-catalogue/neighbours-and-legacy.md) §1.
  - **`CatalogProject::scopeListedOnSite` is deliberately NOT memoised**, so `publiclyListed()`
    re-plucks the site's decision set two or three times per request. That is a considered
    refusal, not an oversight: it is the query that decides what a white-label may show, and a
    cache would have to be invalidated from writes that fire no model events (`upsert()`, mass
    deletes). The scope's own doc-block records the verdict and names the pattern to use if it
    ever becomes hot.
- ⚠️ **Which mode PRODUCTION's catalogue connection runs in is UNRESOLVED**, and the two docs
  that state it disagree — see the boxed note in
  [databases-and-distribution.md](/docs/modules_handbook/shared/project-catalogue/databases-and-distribution.md)
  §2. Code cannot settle it (`config/database.php` is purely env-driven). Check the box, do not
  trust either document, and correct both plus `CatalogProject.php`'s class comment once known.
- **One product decision is open:** the HK project page renders an always-visible VR360 tab
  whose bake can never be created, because both Form Requests hard-bound latitude to 0.5–7.5
  and Hong Kong sits at ~22.3.
- **Three stale CODE COMMENTS were the origin of doc claims corrected here** and will re-seed
  the drift if left: `ResolvesCatalogueCover.php` ("today every catalogue row is a scraped
  url"), `EstateUaeApiClient.php` ("only `catalogue:harvest-uae-details` calls this class"),
  and `CatalogueEditDomainTest.php` ("the gate is inert in that mode").

---

## 6. When the docs run out

The catalogue's code is heavily commented and the doc-blocks explain *why*, not just *what*.
In rough order of usefulness:

- `config/database.php` (the connections), `config/project_catalogue.php` (merge policy and
  provider→connection map), `config/site.php` (the edit gate)
- `src/Analysis/Reference/` — the models, and which connection each pins
- `app/Services/Property/` — merge, completeness, content, detail, federation
- `app/Services/Property/Ingestion/` — the adapter contract and every provider adapter
- `app/Console/Commands/` — 54 catalogue commands, each with a doc-block explaining the
  problem it was written for
- `tests/Feature/Console/` and `tests/Feature/Property/` — the behaviour that is actually
  guaranteed
