# Project Catalogue — neighbours, the retired Analyze tables, and the name traps

> 📍 Part of the project catalogue doc set — the map and routing table is
> [start-here.md](/docs/modules_handbook/shared/project-catalogue/start-here.md).

Third companion to [the module doc](/docs/modules_handbook/shared/project-catalogue/readMe.md)
(what the catalogue *is*) and
[databases-and-distribution.md](/docs/modules_handbook/shared/project-catalogue/databases-and-distribution.md)
(where its rows live).

This one answers a different question: **what else in this codebase touches the catalogue,
and which of those tables are alive.** It exists because the catalogue has a large blast
radius — sixteen site tables carry catalogue keys, eight more tables are its retired
predecessors that a fresh `migrate` still creates, and two unrelated families are both called
"sites". None of that was written down; the only surviving warning about the retired tables
lives in a migration runbook nobody reads twice.

---

## 1. How a site table points at the catalogue

The catalogue may live in a **different database** (see the companion doc), so a site table
can never take a real foreign key to it. The pattern instead is **two columns**, applied by
[`Src\Common\Concerns\BelongsToCatalogue`](/src/Common/Concerns/BelongsToCatalogue.php) and
[`TracksCatalogueUuid`](/src/Common/Concerns/TracksCatalogueUuid.php):

| Column | Used for | Survives |
|---|---|---|
| `catalog_*_id` (integer) | joins and lookups **inside one database** | only while both sides keep their id spaces |
| `catalog_*_uuid` (char 36) | identity **across deployments** | a re-seed, a cutover, an id offset, a package import |

**The uuid is the truth; the integer is a cache.** When the integer drifts — after a master
re-seed, a cutover, or a `catalogue:offset-local-ids` run —
[`catalogue:repair-refs`](/app/Console/Commands/RepairCatalogueReferences.php) rebuilds integer
keys from the stored uuids. But it repairs only **the tables in its own map**
(`RepairCatalogueReferences::TABLES`, ten today), and that map is **not** the list of what
carries catalogue keys. `handle()` iterates the constant and nothing else, so a table missing
from it is skipped with no output and no error.

Three overlapping sets, and confusing them is the trap:

| Set | Count | What it means |
|---|---|---|
| Models applying `TracksCatalogueUuid` | 13 | the uuid twin is filled automatically on save |
| Tables in `RepairCatalogueReferences::TABLES` | 11 | the integer is rebuilt after a drift |
| Site tables carrying a catalogue key at all | 16 | everything below |

| Site table | Model | Catalogue keys | `TracksCatalogueUuid` | Repaired |
|---|---|---|---|---|
| `projects` | [`Src\Property\Project`](/src/Property/Project.php) | project | ✅ | ✅ |
| `bookings` | [`Src\Engagement\Booking`](/src/Engagement/Booking.php) | floor plan | ✅ | ✅ |
| `lead_project_views` | `Src\Lead\LeadProjectView` | project (+ intent milestones) | ✅ | ✅ |
| `lead_floor_plan_views` | `Src\Lead\LeadFloorPlanView` | floor plan + project | ✅ | ✅ |
| `property_analyses` | `Src\Analysis\PropertyAnalysis` | project + floor plan | ✅ | ✅ |
| `catalog_analysis_snapshots` | `Src\Analysis\Reference\CatalogAnalysisSnapshot` | project + floor plan | ✅ | ✅ |
| `catalog_vr_bakes` | `Src\Analysis\Reference\CatalogVrBake` | project | ✅ | ✅ |
| `catalog_publish_reviews` | `Src\Analysis\Review\CatalogPublishReview` | project | ✅ | ✅ |
| `flg_owner_listing_rows` | `Src\FacebookLeadGenerator\OwnerListingRow` | floor plan | ⚠️ trait applied, uuid **never written** | ⚠️ in the map, but see below |
| `rental_estimate_submissions` | `Src\RentalEstimate\RentalEstimateSubmission` | project + floor plan | ✅ | ✅ |
| `saved_layouts` | `Src\Analysis\SavedLayout` | project + floor plan | ✅ | ❌ |
| `catalog_project_highlights` | `Src\Analysis\Highlight\CatalogProjectHighlight` | project | ✅ | ❌ |
| `layout_analyses` | `Src\Analysis\LayoutAnalysis` | project + floor plan | ❌ | ❌ |
| `wealth_deal_presets` | `Src\Wealth\DealPreset` | project (nullable) | ❌ | ❌ |
| `catalog_floor_plan_key_sizes` | `Src\Analysis\Reference\CatalogFloorPlanKeySize` | floor plan | ❌ | ❌ |
| `area_tutorial_stops` | `Src\AreaGuide\AreaTutorialStop` | project ✅ · **landmark (`market_catalyst_id`) — integer only, no uuid** | ✅ project only | ✅ project only |

⚠️ **`flg_owner_listing_rows` is the worst of the set, and it looks fine.** The model applies
`TracksCatalogueUuid`, and the table IS in `catalogue:repair-refs`' map — but the uuid is
**never populated**. The trait fills it in a `saving` model hook, and both jobs that link an
owner row to a plan write through the QUERY BUILDER (`ProcessOwnerListingJob` upserts
`catalog_floor_plan_id` without the uuid in either the row array or the update-column list;
`RematchOwnerListingJob` updates the id twice the same way), so no model event ever fires. The
only Eloquent callers are tests, which is why the suite never noticed. **Consequence: do NOT
run `catalogue:repair-refs --apply` against this table** — for any row that was backfilled and
later re-matched, it would rebuild the id from a stale uuid and overwrite a correct link.
Audit how many rows carry a uuid that no longer matches their id before touching it.

**The five remaining ❌ rows are the ones to know about** — `saved_layouts`,
`catalog_project_highlights`, `layout_analyses`, `wealth_deal_presets`,
`catalog_floor_plan_key_sizes`, plus the LANDMARK half of `area_tutorial_stops`. After a master
re-key they keep pointing at whatever row now holds that integer, and
`catalogue:repair-refs` reports success without having
touched them. `wealth_deal_presets` is the sharpest case: its own migration doc-block promises
the uuid is "insurance that `catalogue:repair-refs` can rebuild it from", and that promise is
unkept — the table is not in the command's map. Either add these tables to
`RepairCatalogueReferences::TABLES` or treat this row as a known gap; do not read the doc-block
as a guarantee.

### The relation, not just the columns

`BelongsToCatalogue` adds no columns. It adds **`catalogueBelongsTo()`**, and that is how a site
model must declare its catalogue parent — never a plain `belongsTo`. (Filling the uuid twin is
the other trait's job: `TracksCatalogueUuid` writes `catalog_*_uuid` from `catalog_*_id` on
save.)

`catalogueBelongsTo()` returns a
[`FederatedBelongsTo`](/src/Common/Relations/FederatedBelongsTo.php), which asks the catalogue
connection first and this deployment's own database for the keys the catalogue did not answer.
It has to: a `catalog_project_id` may name a master row **or** a project this platform created
itself (`catalog_projects.origin = 'own'`, which lives in the site database whenever the
catalogue is a separate one). A plain `belongsTo` resolves on the catalogue connection only,
returns null for the second kind, and renders a blank with no error. Both halves are federated
on purpose — `getResults()` for a lazy `$model->parent`, `getEager()` for `with('parent')` —
because a half-federated relation passes every single-record test and fails only on lists.

All thirteen `TracksCatalogueUuid` models use `catalogueBelongsTo()`. Two boundaries:

- **Not for the catalogue's own family.** `CatalogFloorPlan`, `CatalogMedia`,
  `CatalogProjectSource` and friends keep a plain `belongsTo` — they inherit their parent's
  connection through `Concerns\KeepsRelatedConnections`, and a child never lives in a different
  database from its project.
- ✅ **`AreaTutorialStop` used to be the one site model that got this wrong. Fixed 2026-09-14.**
  It declared a plain `belongsTo(CatalogProject::class, 'catalog_project_id')`, so a stop
  pointing at a project this platform created itself resolved to null and rendered blank, and
  its table had no uuid column to repair from either. It now uses `BelongsToCatalogue` +
  `TracksCatalogueUuid`, `catalogProject()` is a `catalogueBelongsTo()`, migration
  `2026_09_14_100000_add_catalog_project_uuid_to_area_tutorial_stops.php` added the nullable
  indexed `catalog_project_uuid` twin (backfilled **per connection** — the ids are plucked, each
  database is asked for its own uuids, and a missing or collapsed catalogue simply yields
  nothing; never a cross-connection join), and the table is registered in
  `RepairCatalogueReferences::TABLES`. The stop pickers
  (`projectOptions`, `projectsInBounds`, the `StoreRequest` exists-check and `mapInput`'s
  uuid→id resolution) all go through `CatalogueFederationService` now — each side constrained
  and limited on its own connection, merged master-first, deduped by uuid.
- ⚠️ **Still open on the SAME table: the LANDMARK link.**
  `area_tutorial_stops.market_catalyst_id` has no uuid twin,
  `AreaTutorialStopsController::mapInput` still resolves it master-only
  (`MarketCatalyst::where('uuid')->value('id')`), `catalystOptions` is master-only, and the
  catalyst column is **not** in `RepairCatalogueReferences::TABLES`. A master re-key of
  `market_catalysts` breaks landmark stops exactly the way it used to break project stops.
  Impact is nil TODAY because `market_catalysts` is empty in the live catalogue — which is why
  it was left out of scope rather than fixed in passing. Closing it means a dated migration with
  a two-connection backfill, a THIRD entry in the shared `TracksCatalogueUuid` column map (a
  trait booted on all twelve federated site tables, so it wants its own review), federated
  catalyst lookups, and a second column for that table in the repair map.
- ⚠️ **A caveat that applies to every uuid twin, not just this one.**
  `TracksCatalogueUuid`'s `saving` hook re-resolves the uuid from the integer id **master
  first**, falling back to the default connection. In separate-catalogue mode that can overwrite
  a uuid a caller had already resolved through the federation: if a platform-created project's
  integer id also exists in the master, the master's uuid is the one stored. So a stored twin is
  strong evidence of identity, not proof — the federation's own rule ("the question is never
  does the site have this id, but does the MASTER lack it") is what decides.

Three things that are **not** on the table above and are often assumed to be:

- **`focus_projects`** ([`Src\Property\FocusProject`](/src/Property/FocusProject.php)) reaches
  the catalogue only *indirectly*, through `projects.catalog_project_id`.
  `FocusProjectRepository` throws if that is empty, which is the closest thing to a
  constraint.
- **Property Match** (`src/PropertyMatch/`) does **not** join the catalogue at all.
- **The Appointment Engine** stores no catalogue key either. Since its `ae_projects` shadow
  merged into `projects` (2026-09-09) its project IS the CRM `projects` row, so its only reach
  is eager-loading `project.catalogProject`. ⚠️ "catalogue" throughout the appointment-engine
  docs means `NodeCatalogue`, the workflow node-type registry — nothing to do with this module.

**Searching across the boundary is federated, never a subquery.** A `JOIN` into `catalog_*`
works while the two collapse onto one database and fatals when they do not — and a
`whereHas('catalogProject')` is the same join in disguise, except that it does not fatal
(see [databases-and-distribution.md](/docs/modules_handbook/shared/project-catalogue/databases-and-distribution.md)
§6, and §6.2 for the replacement).

`Src\Property\Project` resolves `matchingCanonicalIds()` and feeds a bounded id→name `CASE`.
⚠️ **Both were master-only until 2026-09-14** — genuinely cross-DB safe, but they asked only
`CatalogProject::query()`, which is pinned to the `catalogue` connection, so a working project
linked to a platform-created (`origin = 'own'`) row was never matched by `searchCanonical` and
fell through to ordering by its stale `projects.name`. They now resolve through
`CatalogueFederationService::matchingReferenceIds()` / `referencedRows()`, which ask each owning
database. `scopeOrderByCanonicalName()` builds its `CASE` from that plucked map and emits **no
subquery at all** — names travel as bindings, never interpolated.

The public listing **reaches the same answer by a different route**, not the same one:
[`BuildNewProjectListing`](/app/Actions/BuildNewProjectListing.php) uses neither
`matchingCanonicalIds()` nor an id→name `CASE`. It runs one shared `$constrain` closure against
`federation->master()` and `->local()` directly, then ranks with a uuid pin `CASE`.

---

## 2. The eight retired Analyze tables — present, empty, and load-bearing by accident

Before the catalogue existed, property data lived in an "Analyze" schema. The redesign moved
every row into catalogue-family tables. **The old tables were never dropped.** A fresh
`php artisan migrate` still creates all eight, and nothing writes to them.

| Legacy table | Canonical successor |
|---|---|
| `properties` | `catalog_projects` |
| `property_details` | `catalog_projects` |
| `property_floor_plans` | `catalog_floor_plans` |
| `property_layout_types` | `catalog_floor_plans` |
| `floor_plan_analysis` | `catalog_floor_plan_analytics` |
| `fields_address` | `catalog_projects` |
| `airbnbs` | `market_airbnbs` |
| `property_agents` | `market_property_agents` |

Created by
[`2026_06_13_000001_create_market_reference_tables.php`](/database/migrations/2026_06_13_000001_create_market_reference_tables.php)
and `2026_06_22_000001_create_property_agents_table.php`. The authoritative list is the
constant at the top of
[`AuditRedesign`](/app/Console/Commands/AuditRedesign.php) — trust that, not this table, if
the two ever disagree.

**Do not confuse a legacy table with its live namesake.** `property_analyses`,
`saved_layouts` and `layout_analyses` all start with the same words and are **current**
petav3 tables (§3).

### The retirement machinery

| Command | Role |
|---|---|
| `catalogue:backfill-redesign` | move legacy rows into the redesigned schema. Idempotent |
| `catalogue:apply-identity-decisions` | apply the owner-reviewed CSV for legacy rows whose identity was ambiguous. **All-or-nothing** — one bad row rejects the whole file, zero writes |
| `catalogue:audit-redesign` | the **drop gate**. Reconciles the backfill and proves zero runtime references outside migrations / tests / backfill tooling. Non-zero exit on any failure |

[`catalog_legacy_crosswalks`](/database/migrations/2026_07_20_100007_create_catalog_legacy_crosswalks_table.php)
is the ledger those three write and read: **one deterministic outcome per legacy row** —
`migrated`, excluded under a named rule, or unresolved — unique on `(source_table, source_id)`
and pointing at `(target_table, target_id)` with the `match_rule` and a reason. It is
site-side, not on the master. *The drop gate may not pass while any legacy row is absent from
this ledger or still unresolved.*

### ⚠️ Do not drop the eight tables yet

The only warning about this currently lives outside the handbook, in
[`docs/propertysifu-my-migration-runbook.md`](/docs/propertysifu-my-migration-runbook.md):
*"Do not delete `properties`, `property_details`, `property_floor_plans`, …"*. Restating it
here so it is findable from the module it belongs to. The sequence is: backfill → resolve
every crosswalk row → `catalogue:audit-redesign` exits zero → **only then** write a drop
migration. Until that day they are empty tables costing nothing but confusion.

The one surviving mention of a legacy table in live code is a **provenance string**, not a
read: `FindNearbyProjectRoomRental` matches `source_key like 'petav2:property_floor_plans:%'`.
Leave it alone — it is naming where a row came from, not querying the old table.

---

## 3. The analysis chain — three tables, three grains

Sitting on top of the catalogue is a precompute pipeline. Each step has a different grain,
which is the whole reason there are three tables and not one.

```
catalog_floor_plans (catalogue, may be on the master)
        │  catalogue:precompute-analysis
        ▼
catalog_analysis_snapshots     grain: (floor plan, engine_version, input_hash)
        │                      the full analysis payload; is_current flips,
        │                      history is never overwritten
        │  layouts:project
        ▼
layout_analyses                grain: one row per analysed layout — a FLAT read
        │                      model, so a list page needs no JSON parsing
        ▼
saved_layouts                  grain: one row per layout a MEMBER shortlisted
```

- `catalog_analysis_snapshots` replaced a 6-hour Redis entry, which is why identity includes
  `engine_version` and `input_hash`: a changed engine or changed inputs produce a new row
  rather than silently overwriting the old answer.
- **It is petav3-owned and deliberately NOT pinned to the catalogue connection** — one of
  **four** site-side models sitting in `src/Analysis/Reference/` with no `$connection` line:
  `CatalogAnalysisSnapshot`, `CatalogVrBake`, `CatalogFloorPlanKeySize` and
  `SiteCatalogProject`. The other 19 files there pin
  `protected $connection = CatalogProject::CONNECTION`. **The folder name means nothing —
  only the `$connection` line says which database a model reads.** Never infer it from the
  path. `AuditRedesign` encodes the exception for the first two explicitly.
- Read through [`UnitAnalysisStore`](/src/Analysis/Services/UnitAnalysisStore.php)
  (`get` / `put` / `stale`), consumed by
  [`ProjectDetailController`](/app/Http/Controllers/Main/Site/ProjectDetailController.php).
- **`catalogue:precompute-analysis` is NOT scheduled.** The only catalogue command in
  [`app/Console/Kernel.php`](/app/Console/Kernel.php) is the nightly `catalogue:sync`, and
  even that **ships disabled**; the scrapers are manual and credit-gated.
- **But the command is not the only writer.** Four paths write `catalog_analysis_snapshots`,
  all through `UnitAnalysisStore::put()`, so do not assume a snapshot's existence implies
  someone ran the command:
  1. `catalogue:precompute-analysis` — the bulk pass.
  2. `layouts:project` — writes as it flattens into `layout_analyses`.
  3. **A page view.** `ProjectDetailController::resolveAnalysis()` is a read-through: it
     stores whatever it had to compute. `UnitAnalysisStore::get()` returns null both when no
     current snapshot exists AND when its `input_hash` no longer matches the unit's facts, so
     a missing or stale snapshot is silently refilled by the next visitor.
  4. **An admin save.** `POST /{country}/projects/{slug}/analysis/save`
     (`main.site.projects.analysis.save`, gated on `MANAGE_PROJECTS`) stores every analysable
     plan on the project at once.
- `catalogue:copy-uae-snapshots` pushes UAE payloads up to the master, and refuses to run
  unless the deployment reads the master live.

The member-facing half of this chain — Layout Analysis and the shortlist — is documented in
[Analyze Property](/docs/modules_handbook/main/analyze-property/readMe.md) ("Layout Analysis —
one row per unit" and "The shortlist — `saved_layouts`"). Read that for the screens; read this
for where the data comes from.

---

## 4. The name trap: two unrelated "site" families

Both are site-side. They are not related, and the similarity has cost time before —
[`Src\Common\Site`](/src/Common/Site.php) opens with a warning about it.

| | `market_sites` + `market_site_countries` | `sites` + `site_catalog_projects` |
|---|---|---|
| Grain | one **hostname of THIS deployment** → which countries it may show | one **whole deployment** sharing the catalogue (white-label / regional) |
| Model | `Src\Common\MarketSite` | `Src\Common\Site`, `Src\Analysis\Reference\SiteCatalogProject` |
| Resolved by | `MarketSiteResolver` + `SetPublicSite` middleware, per REQUEST | `Site::current()` ← `config('site.key')` ← `SITE_KEY`, per DEPLOYMENT |
| Keyed on | `country_id` | **`catalog_project_uuid`** — never the master's integer id |
| Controls | `all_markets` boolean; a default row catches unknown hosts | `catalogue_mode` = `inherit` / `explicit`; an explicit row always wins |
| Managed at | Setting → Markets ([`MarketsController`](/app/Http/Controllers/Manage/Setting/MarketsController.php)) | `SiteCatalogProjectRepository` |

Rule of thumb: **`market_sites` answers "what may this hostname show?"; `sites` answers "which
catalogue records does this deployment list?"** The second is covered in the module doc's
*Site listing layer*.

---

## 5. Where scraped data enters — the `reference` connection

Providers do not write the catalogue directly. Every provider registered in
[`config/project_catalogue.php`](/config/project_catalogue.php) streams records through the
same [`ProviderAdapter`](/app/Services/Property/Ingestion/ProviderAdapter.php) contract, and
the ingestion orchestrator turns those into catalogue *sources*. **What differs is where each
adapter READS from, and only some of them use the `reference` connection:**

- **Database readers** — `house730`, `centanet`, `centanet-estates` (all `HkReferenceAdapter`
  subclasses), `propertysifu` and `propertyfinder` read the **external, shared scraped
  database** over the `reference` connection (`REFERENCE_DB_*`). These are the ones that
  declare `providers.*.connection`, and the only three classes that read that key are
  `HkReferenceAdapter`, `PropertySifuMyAdapter` and `PropertyFinderUaeAdapter`.
- **Filesystem-inbox readers** — `edgeprop` (`EdgepropFileAdapter`), `edgeprop-nl`
  (`EdgepropNewLaunchAdapter`) and `iproperty` (`IpropertyAdapter`) read a JSON artifact
  dropped by a crawl. They have **no `connection` key at all** and never touch the reference
  database. Do not go looking for their tables.

| Adapter / command | Reads (over `reference`) |
|---|---|
| `HkReferenceAdapter` (+ House730, Centanet projects/estates) | `ih_hk_newproj`, `ih_hk_new_projects`, `ih_hk_projects`, `ih_hk_estates`, `ih_estate_valuations` |
| `PropertySifuMyAdapter` | `ih_my_projects`, `ih_my_layouts` |
| `PropertyFinderUaeAdapter` | `ih_uae_projects`, `ih_uae_layouts` |
| `market:import-hk` | `ih_hk_schools`, `ih_hk_hma_profiles`, `ih_hk_listings`, `ih_hk_doc_pages`, `ih_hk_indices`, `ih_hk_transactions`, `ih_gba_reference` |
| `market:import` | `edgeprop_projects`, `airbnbs`, `propsense_agents` |

⚠️ **The `reference` connection comment in `config/database.php` is only one-third stale, and
the third that is wrong is a name trap.** It names `edgeprop_projects`, `property_agents` and
`airbnbs`. Two of those three are still real reference tables:
[`market:import`](/app/Console/Commands/ImportMarketData.php) reads `edgeprop_projects` →
`catalog_projects`, `airbnbs` → `market_airbnbs` and `propsense_agents` →
`market_property_agents`. Only **`property_agents` is wrong** — the reference-side agents table
is `propsense_agents`; `property_agents` is the RETIRED **local** table of §2. `airbnbs` is
*both*: a live reference-side source AND a retired local table of the same name. When you read
either name, check which connection is in play before concluding anything.

These are read live and never imported wholesale — by design, not as a temporary shortcut.
The test suite points `REFERENCE_DB_*` at the local test database instead
([`phpunit.xml`](/phpunit.xml)).

---

## 6. Known documentation debt

Recorded here so the next person does not rediscover it:

- **`docs/planning/master-catalogue-shared-database-plan.md` is cited by 22 files and has
  never existed in this repository** (four of those are the handbook docs describing the debt;
  18 still dangle). Its content now lives in
  [databases-and-distribution.md](/docs/modules_handbook/shared/project-catalogue/databases-and-distribution.md);
  the two citations in the module doc have been repointed, but the other 14 — in
  `config/database.php`, `BuildNewProjectListing`, `CatalogueFederationService`,
  `CutoverToMasterCatalogue`, `OffsetLocalCatalogueIds`, `TracksCatalogueUuid`,
  `scripts/seed-master-catalogue.sh`, `database/migrations/catalogue/README.md` and seven
  migrations — still point at nothing. The migrations are deliberately untouched
  (GUIDELINES §7: never modify a committed migration).
- ~~Only ~10 of the catalogue-family commands are documented.~~ **Closed 2026-09-12:** all 51
  are now in [commands.md](/docs/modules_handbook/shared/project-catalogue/commands.md).
- **The module doc is one `## How it works` section about 960 lines long with a single
  sub-heading.** Adding to it is fine; finding anything in it is not.

---

## 7. Related files

- Cross-database keys: [`src/Common/Concerns/BelongsToCatalogue.php`](/src/Common/Concerns/BelongsToCatalogue.php),
  [`src/Common/Concerns/TracksCatalogueUuid.php`](/src/Common/Concerns/TracksCatalogueUuid.php),
  [`app/Console/Commands/RepairCatalogueReferences.php`](/app/Console/Commands/RepairCatalogueReferences.php)
- Legacy retirement: `app/Console/Commands/{BackfillRedesign,AuditRedesign,ApplyIdentityDecisions}.php`,
  [`database/migrations/2026_07_20_100007_create_catalog_legacy_crosswalks_table.php`](/database/migrations/2026_07_20_100007_create_catalog_legacy_crosswalks_table.php),
  [`docs/propertysifu-my-migration-runbook.md`](/docs/propertysifu-my-migration-runbook.md)
- Analysis chain: [`src/Analysis/Reference/CatalogAnalysisSnapshot.php`](/src/Analysis/Reference/CatalogAnalysisSnapshot.php),
  [`src/Analysis/Services/UnitAnalysisStore.php`](/src/Analysis/Services/UnitAnalysisStore.php),
  `src/Analysis/{LayoutAnalysis,SavedLayout,PropertyAnalysis}.php`,
  `src/Analysis/Services/LayoutAnalysisProjector.php`,
  `app/Console/Commands/{PrecomputeCatalogueAnalysis,ProjectLayoutAnalyses,CopyUaeSnapshotsToMaster}.php`,
  [`main/analyze-property/readMe.md`](/docs/modules_handbook/main/analyze-property/readMe.md)
- The two site families: [`src/Common/Site.php`](/src/Common/Site.php),
  [`src/Common/MarketSite.php`](/src/Common/MarketSite.php),
  `src/Common/Services/MarketSiteResolver.php`,
  [`app/Http/Middleware/SetPublicSite.php`](/app/Http/Middleware/SetPublicSite.php),
  [`manage/markets/readMe.md`](/docs/modules_handbook/manage/markets/readMe.md)
- Ingestion surface: `app/Services/Property/Ingestion/Adapters/`,
  [`config/project_catalogue.php`](/config/project_catalogue.php),
  [`app/Console/Commands/ImportHkMarketReference.php`](/app/Console/Commands/ImportHkMarketReference.php)
