# Project Catalogue (shared)

> 📍 **New to this module, or pointing an AI at it? Open
> [start-here.md](/docs/modules_handbook/shared/project-catalogue/start-here.md) first.**
> Catalogue knowledge spans ~30 markdown files across four locations; that file is the map —
> a 60-second model, the traps that have already cost time, and a routing table from "my task
> is X" to the two or three files that actually answer it. This doc is one of its
> destinations, not the front door.

## What it does

The canonical, source-agnostic project database behind every property
surface: one `catalog_projects` row per real-world development (stable uuid
for cross-deployment sync, per-country slug for public URLs), fed by
provider source records (`catalog_project_sources`) that merge
deterministically, owning the floor plans (`catalog_floor_plans`), media
(`catalog_media`), versioned AI prose (`catalog_ai_contents`) and ingestion
run history (`catalog_sync_runs`). Working projects reference the catalogue
and never override it; admin overrides are field-whitelisted and survive
re-ingestion. Contract: `docs/property-detail-contract.md`.

**Where the rows physically live** — the `catalogue` / `catalogue_master` /
`catalogue_staging` / `reference` connections, the shared `master_projects` database, the
live-vs-copy modes behind `CATALOGUE_USE_DEFAULT_CONNECTION`, the id spaces that keep two
databases from colliding, and every command that moves a catalogue between deployments
(`catalogue:cutover`, `:offset-local-ids`, `:mirror-from-master`, `:export`, `:import`,
`:verify-sync`, `:explain-drift`) — is documented separately in
[databases-and-distribution.md](/docs/modules_handbook/shared/project-catalogue/databases-and-distribution.md).
**Read it before touching anything that names a connection**, and before assuming a bare
`DB::table('catalog_…')` reaches the catalogue.

**What else touches the catalogue** — the sixteen site tables that carry `catalog_*_id` +
`catalog_*_uuid` pairs (and `catalogue:repair-refs`, which rebuilds the integers from the
uuids), the **eight retired Analyze tables** a fresh `migrate` still creates and that must not
be dropped yet, the `catalog_analysis_snapshots` → `layout_analyses` → `saved_layouts`
precompute chain, the `market_sites`-vs-`sites` name trap, and the `reference` connection the
scrapers land in — is in
[neighbours-and-legacy.md](/docs/modules_handbook/shared/project-catalogue/neighbours-and-legacy.md).

**Sub-module:** [VR360 Aerial Panoramas](/docs/modules_handbook/shared/project-catalogue/vr360/readMe.md)
— a 360° view captured from Google Photorealistic 3D Tiles at a chosen floor,
reviewed and approved per project, with the site plan optionally baked onto the
rooftops. It is a **VR360** tab on this module's Show page and a *360° Aerial*
tab on the public project page; the capture itself runs on a
[separate bake box](/docs/modules_handbook/production-setup/vr-bake-worker.md).

**Nav:** `/manage/property/catalog/subsale` (the bare `/manage/property/catalog` is a 302 to it, query string forwarded, so older links survive). In the **Operations** suite it is the
**Subsale Database** pill under Channel → **Portal** → **Analyze Property**
(the analyses read from these records — see
[Portal Engagement](/docs/modules_handbook/manage/portal-engagement/readMe.md));
its sibling pill **New Project Database** is the scraped review at
`/manage/property/catalog/new-projects` (the old `/scraped` path still
redirects there) — provider tabs plus
`?tab=duplicates` (**Duplicates to merge**), `?tab=publish` (**To Publish**),
`?tab=in-review` (**In Review** — the panel's own worklist),
`?tab=published` (**Published**, the far end of the same pipeline) and
`?tab=manual` (hand-typed records).
The strip is CLUSTERED by kind (2026-08-22, the funnel Leads tab's inline
style): **Sources** (the three feeds — one question asked three times),
**Merge**, and **Publication** in the order a record walks the pipeline —
To Publish → In Review → Published.
**`?tab=records` (All records) LEADS the strip and is the tab the page opens
on** — an unknown or absent `?tab=` falls back to it. It was hidden from
2026-08-07, while every row came from a crawl and it near-duplicated the
provider tabs; it returned once the page also carried hand-typed records,
because it is the only tab that answers "everything, however it got here".
⚠️ **Two `?tab=` keys were RENAMED when the strip was clustered** —
`combined` → `records` and `combine` → `duplicates` — because a read-only
inventory and a worklist that DELETES canonicals were one character apart in
both the strip and the URL, far too subtle for a destructive action. The old
keys survive only through `CatalogScrapedController::LEGACY_TABS`, which
forward-maps them; read the `TAB_*` constants, never this paragraph, for the
current keys.
all in-page `?tab=` pivots, never sidebar entries (both pages mount `PortalEngagementTabs`,
and the Portal sidebar entry's `prefixes` keep the nav lit on both — the
active pill resolves by LONGEST prefix, since the Subsale path is a prefix of
the scraped one). The fourth pill, **Developers**, is the reusable developer
directory at `/manage/property/developers`: canonical identities plus the
joint developer / subsidiary / brand / landowner / related-company choices
shown after a primary developer is selected on a project. The **New Project**
and **Subsale** suites keep their direct
sidebar entries (they have no Portal section). Sidebar entries/pills gated on
`view-projects` (the pills also accept `view-subsale`, matching the routes).

## How it works

- **Merge** — `App\Services\Property\ProjectCatalogueMergeService`:
  manual override > provider priority (`config/project_catalogue.php`) >
  newest non-null active source; `market_segments` merges as a set UNION,
  `name_translations` per locale; no fuzzy merging — cross-provider identity
  is explicit (`attachSource`, `catalogue:hk-map-sources`).
- **Classification vocabulary** — `tenure` and `property_type` are folded onto
  the sets declared on `CatalogProject` (`TENURES` / `PROPERTY_TYPES`) by
  `App\Services\Property\CatalogueClassificationNormalizer`, called from
  `ProjectCatalogueMergeService::resolve()` — one rule covering every ingest
  path, present and future, rather than one per adapter. The stored value IS
  the label, not a slug: every reader already held `'Freehold'` verbatim.
  The list is `NORMALIZED_CLASSIFICATION_FIELDS`; read it rather than assuming.
- ⚠️ **`sale_status` is NOT normalized — it is DERIVED**, and the resolution
  loop skips it (`DERIVED_FIELDS`, alongside `price_min` / `price_max`). After
  the loop `resolve()` either computes it from the completion year via
  `CatalogProject::deriveSaleStatus()` — for the providers listed in
  `config('project_catalogue.derived_sale_status_providers')` — or keeps the
  feed's wording VERBATIM, which is what the HK providers do. So the same
  column is authored two different ways depending on the provider, and
  `SALE_STATUSES` is not a guarantee about what is stored.
  Three things to know before touching it:
  - **`property_type` is multi-valued**, stored comma-joined and re-emitted in
    `PROPERTY_TYPES` declaration order — otherwise `'A,B'` and `'B,A'` are two
    strings for one fact and every grouping downstream splits in half.
  - **An unmappable value returns null, which resolution treats as "the source
    said nothing"** — the existing canonical value survives. Overwriting a good
    value because a later scrape sent junk would lose data on every sync.
  - **Every canonical member is seeded into the alias table as an alias of
    itself**, so normalising an already-clean value is a no-op. That is not
    belt-and-braces: resolve() re-runs on every sync, so the already-clean case
    is the common one, and a member missing its self-entry gets silently
    dropped on the second pass (`Shop/Shoplot` did exactly this before it was
    generated rather than hand-written).

  Rows written before the normalizer existed keep their provider spelling
  forever, because resolution only ASSIGNS from a non-null source value and
  never rewrites one the feeds stopped sending. `catalogue:normalise-classification`
  is the cleanup — dry-run by default, `--apply` to write,
  `--clear-unmappable` to also NULL values no member covers. It groups with
  `COLLATE utf8mb4_bin`, because the table is `utf8mb4_unicode_ci` and a plain
  `GROUP BY` folds `Condominium/Apartment` into `Condominium/apartment` and
  scores both "already canonical". The 2026-08-07 run took tenure 12 → 3
  values, sale_status 15 → 1, and property_type from 831 distinct strings to
  305 combinations of exactly 19 atoms.
- **One layout, two payload shapes** — PropertySifu describes a layout twice:
  `unitTypes` carries a FORMATTED size (`"882 sqft"`) and `ih_my_layouts` the
  integer. `syncFloorPlanContributions` matches on `(project, name, sqft)`, and
  `where('sqft', null)` becomes `sqft IS NULL` — which matches only another
  sizeless row — so the formatted variant used to mint a parallel ghost beside
  the real layout. `CatalogProject::officialFloorPlans()` back-fills that null
  in memory to make both resolve to one identity, which is why the review screen
  showed two rows that looked *identical* and held one back for no visible
  reason. Ingestion now recovers the number itself (`planSqft()`) and, if it
  still cannot, matches by NAME rather than insisting on null-for-null — so the
  read-time patch is a backstop, not the thing holding the page together.
  `catalogue:merge-duplicate-layouts` cleaned up what the old matcher wrote
  (dry-run by default). It is a MERGE, not a delete: the ghost usually owned the
  floor-plan image. The 2026-08-07 run folded 1,599 ghosts into their sized twin
  across 259 catalogues, re-parenting 1,418 media and filling 2,290 blank
  fields; it skips any name that maps to more than one size, since a sizeless
  row cannot be assigned to one of them without guessing.
- **A held-back layout can be published from the tab, and the live order is
  the admin's to set** (2026-08-30) — the two controls the status display owed
  the reader, because it named a state nobody could change from the screen
  reporting it.
  **Put on live site** (`POST {id}/floor-plans/{planId}/publish` →
  `CatalogFloorPlanRepository::claim()`) pins every value the row already
  carries into `manual_overrides`, which is the second half of
  `isAdminOwned()` — no value changes, and `name` is always set so a sparse row
  still ends up genuinely published rather than reporting success and staying
  hidden. Shown ONLY where `floorPlanPublication()` says `can_publish`: a row a
  live feed still syncs stays hidden however hard it is pinned, so the button
  there would be a lie. The flash re-reads publication AFTER the write and says
  what actually happened, because a claimed row can still fold into a twin —
  promising "now visible" on the one screen built to be honest would be the
  worst place to guess.
  **Reorder** (`PUT {id}/floor-plans/order`, declared BEFORE the `{planId}`
  routes or `order` is matched as an id) writes `catalog_floor_plans.position`
  1..N for the whole visible list in one transaction. `position` outranks every
  derived rule in `officialFloorPlans()`'s sort — everything below it is a
  FEED's internal numbering, which says nothing about how a developer presents
  its units. NULL means "no opinion" and keeps the derived order, sorting after
  the arranged rows, so a project is arranged by hand or by the feed and never
  half of each; a layout the feed adds later lands at the end rather than
  jumping into the middle of an order someone chose. Up/down arrows rather than
  the media section's number inputs: a handful of rows, and the point is to
  match a sequence the admin can see. `position` is deliberately NOT in
  `ADMIN_EDITABLE_FIELDS` — it would render a stray input in the floor-plan
  form and be pinned as a manual override.
- **The Floor plans tab states what the LIVE page does with every row**
  (2026-08-30) — `CatalogProject::floorPlanPublication($providerCode)` returns
  `status` (`live` / `merged` / `hidden`), the `position` on the public page,
  the survivor a merged row folded `into`, and for a held-back row a `reason`
  naming the action that would publish it. The tab is ordered to match the
  public page, held-back rows grouped underneath, each row carrying its source
  chips and its live position number, with `N of M layouts appear on the live
  site` in the header. It is **derived FROM `officialFloorPlans()`**, never
  beside it — a second implementation of the publication rule is exactly how
  the Review preview and the public page drifted into disagreeing, which is the
  bug this exists to expose. `merged` and `hidden` must never share a badge:
  a merged row's facts ARE live on another row, a hidden row's reached nothing,
  so one shared "not live" label has an admin re-typing values already
  published. Both floor-plan bugs reported in this module were noticed days
  late on the PUBLIC page; the tab that caused them said nothing.
- **Merge is a BUTTON, not only a command** (2026-08-28) — the Floor plans tab
  carries a Merge control per row (`Partials/CatalogFloorPlanMergeModal.vue` →
  `POST {id}/floor-plans/{planId}/merge`), placed BEFORE delete because when
  two rows are the same layout, merging is the right action and deleting throws
  away whatever that row uniquely held. It is offered only when the project has
  a second plan. The modal previews what moves — images, source links,
  analytics history, and each blank field the duplicate will fill — because the
  merge is irreversible and the facts that move are its whole point; the flash
  then names the fields it actually filled. The preview reads
  `CatalogFloorPlan::MERGE_RESCUABLE_FIELDS`, shipped as the
  `floorPlanMergeFields` prop rather than restated in Vue, and mirrors
  `CatalogFloorPlan::rescuableFills()` — the ONE rule the command and the
  button now share, so they cannot drift into rescuing different fields.
  Survivor must be on the same project: both rows are read through the parent's
  own connection and integer key, so a `keep_id` naming another project's plan
  is simply not found.
- **Duplicates under two NAMES need `--ghost` / `--keep`** (2026-08-28) — the
  scan above only pairs a sizeless row with a sized row of the SAME name, so
  the same layout filed under two names is invisible to it and publishes
  twice: Skyline Embassy carried `A` and `A / A1`, both 521 sqft, one holding
  the unit count and both source links, the other the images and the price,
  and the public page listed both. Deciding those are one layout takes a human
  reading them — hence a named pair on the command line, validated (both exist,
  same project, not the same row) because the merge is irreversible. Delete is
  the wrong tool here and the admin UI offers only that: it would have dropped
  the 306-unit count and two source contributions that live on the ghost.
- **Analytics history blocks nothing** (2026-08-28) — `catalog_floor_plan_analytics`
  is the plan's OWN derived result, inside the catalogue, so neither
  `CatalogFloorPlanRepository::delete()` nor `merge()` refuses over it.
  Bookings, owner listing rows and saved property analyses still do: those are
  records OUTSIDE the catalogue that a delete would leave pointing at nothing.
  The old guard failed its own test twice over — `delete()` already contained
  the line that sweeps those rows, which the guard made unreachable, and
  deleting the whole PROJECT never blocked on them at all
  (`CatalogProjectRepository::delete()` just clears them), so the smaller,
  safer operation was the stricter one. 9,222 plans were undeletable for a
  reason no admin could act on, most held by an empty `petav2_import` row
  carrying `total_room_rental` 0. On a MERGE the history is not swept but
  MOVED, for the same reason the media is — the layout still exists under the
  keeper's name, and `petav2_import` rows carry room-rental facts that
  `FindNearbyProjectRoomRental` serves on OTHER projects' pages and no engine
  can recompute. Two invariants ride on that move and the table has no foreign
  key to enforce either: identity is unique on
  `(plan, engine_version, input_hash)`, so a ghost row the keeper already holds
  is dropped rather than aborting the merge; and `is_current` is one row per
  plan, so an incoming row arrives demoted unless the keeper has no current
  result of its own to lose.
- **A layout the ADMIN maintains is official too** (2026-08-28) — the provider
  filter in `officialFloorPlans()` keeps `isAdminOwned()` plans as well as the
  provider's own: **no ACTIVE source backs it AND a human has curated it**
  (`manual_overrides` non-empty). A feed carries the layouts it happened to
  publish, not the project's whole unit catalogue, and the admin floor-plan
  form exists to finish it.
  Both halves are load-bearing. Unbacked alone also describes the generic
  market rows a re-derive leaves behind (`2 Bedroom`, `3 Bedroom`, no
  overrides, no `updated_by`) — 222 contribution-less ones on
  PropertySifu-backed projects, plus 302 whose only evidence is a switched-off
  source, 79 of those published. Curated alone also describes a row a live feed
  still syncs that an admin merely corrected. Neither is this project's
  official unit type.
  **Backing means an ACTIVE contributing source, not merely a contribution
  row.** A plan whose only evidence comes from a source the catalogue no longer
  syncs — `petav2_legacy` above all, kept as provenance and never refreshed —
  is as unbacked as one with no evidence at all.
  Two projects reported it, and neither screen said why: **ARRA Residences**
  published 1 of 5 layouts (the other four typed by hand, no feed had ever
  contributed them), and **Branniganz Suites** published 1 of 4 — its curated
  A/B/C were left backed only by inactive `petav2_legacy` once the PropertySifu
  rows duplicating them were removed. That second one is the trap worth
  remembering: **deleting a duplicate can unpublish the row it duplicated**,
  because merging carries the provider contribution onto the survivor and
  deleting does not. The Review screen's publish preview had kept
  contribution-less plans all along (`CatalogReviewController::hasReviewProvider()`)
  — **two read models disagreeing about what publishes is the shape of this
  bug**, and the disagreement is what hid the first case: the four held-back
  rows were labelled "duplicate", which they were not.
- **Ingestion** — `catalogue:sync {provider}` resolves a `ProviderAdapter`
  from the config registry into `CatalogueIngestionService`: one savepoint
  per record (zero partial writes), per-source media reconciliation, stale
  marking only after complete clean full snapshots, upstream watermark
  staleness flags, run history in `catalog_sync_runs`. Scheduled entries
  ship disabled (`project_catalogue.sync_enabled`).
  - **`catalog_project_sources` holds more than provider project sources —
    `source_scope` is the discriminator.** The ingestion service sets and filters
    on it, and rows of different scopes are unrelated kinds that happen to share
    a table (the UAE snapshot store writes `uae-details` / `uae-community` rows
    there, for instance). A query over that table that does not constrain
    `source_scope` is almost certainly wrong. The word appears nowhere else in
    this doc set — treat the column as load-bearing, not bookkeeping.
  - **There is a SECOND ingestion stream, and no adapter implements it yet.**
    `CatalogueIngestionService` branches on `$adapter instanceof
    UnitValuationProviderAdapter` and runs `UnitValuationIngestionService` after
    the project stream of the SAME run, folding its failures into the run
    verdict. That stream is what fills `catalog_buildings`, `catalog_units`,
    `catalog_unit_valuations` and their attribution tables — which is why those
    are empty today. Empty means "no producer", not "retired".
  - ⚠️ **"Stale marking" understates it — a full run HARD-DELETES.** Nothing in
    the catalogue family soft-deletes; no `catalog_*` model uses `SoftDeletes`.
    After absent sources deactivate, the same transaction runs
    `deleteIfSafelyOrphaned()`, and each record runs
    `pruneUncontributedFloorPlans()`, which removes any plan that lost its last
    contribution. The cascade takes floor-plan/unit/building sources, units,
    buildings, sources, floor plans, `catalog_media` and `catalog_ai_contents`,
    then the `catalog_projects` row itself. The media rows go as a `HasMany`
    mass delete, so **no model event fires** — the GCS object and the `media`
    row behind `media_id` are orphaned, unlike the admin delete path which goes
    through `MediaService::delete()`.
  - ⚠️ **The delete blocker list does not consult publication, or most of the
    neighbours.** `retainedDependencies()` checks only working projects,
    catalogue and floor-plan `manual_overrides`, `flg_owner_listing_rows`,
    bookings, `property_analyses`, `catalog_floor_plan_analytics`, active
    sources and unit-valuation history. It does **not** look at `published_at`,
    `site_catalog_projects`, `catalog_publish_reviews`,
    `catalog_project_highlights`, `catalog_vr_bakes`, `lead_project_views`,
    `lead_floor_plan_views`, `saved_layouts`, `catalog_analysis_snapshots` or
    `rental_estimate_submissions` — and none of those carry a database-level
    foreign key. So a PUBLISHED, feed-only record with no human edits can be
    deleted without complaint, leaving every one of those dangling. What stops
    it in practice: a `--limit` run is never full, any failed record skips stale
    marking, and a full run that saw zero records refuses outright.
- **AI content** — `CatalogueContentService::refresh` (behind
  `ai_content_enabled`) dispatches `App\Jobs\Ai\GenerateCatalogueContent`
  per launch locale when the factual hash changed; versions are append-only
  and a late response is SUPERSEDED, never latest.
- **Admin CRUD** — `GET|POST /manage/property/catalog`, `PUT|DELETE
  /manage/property/catalog/{id}` (`MANAGE_PROJECTS`): create, full-field edit
  and delete of a canonical row, covering every column in
  `CatalogProject::ADMIN_EDITABLE_FIELDS` (the table minus id/uuid,
  `published_at` and the system bookkeeping columns). One shared modal —
  `Catalog/Partials/CatalogProjectFormModal.vue` over
  `CatalogProjectForm.vue` — serves create (index) and edit (Show, also
  reached by the index row action via `?edit=1`). Writes go through
  `Src\Analysis\Repositories\CatalogProjectRepository`, which **pins every
  CHANGED sync-owned value in `manual_overrides`** (`overrideProtectedFields()`
  = editable minus the workflow-owned slug/country/parent) — without the pin the next
  `catalogue:sync` silently reverts the edit; untouched fields are never
  pinned, because an override is also a source-reassignment blocker. Delete
  refuses (nothing written) while working projects, bookings, owner listing
  rows, saved analyses or unit valuation history still reference the row;
  catalogue-owned children are swept, rental-estimate snapshots are unlinked
  and phases are ungrouped. `PUT {id}/overrides` remains the separate NARROW
  quick-override endpoint guarded by
  `config('project_catalogue.project_override_fields')`; reset
  (`DELETE {id}/overrides/{field}`) accepts the wider protectable set.
- **List filtering** — the index is on the shared §14 pattern
  (`CatalogQueryRequest` + `ResolvesListQuery` + `useResourceIndex` /
  `FilterDrawer` / `ActiveFilterChips`). Dimensions (six —
  `CatalogQueryRequest::$filterable`): **data provider**, country, market
  segment, state, **property type**, publication. ⚠️ **The page opens on a
  DEFAULT working set, not on everything**: with no query string the request
  merges `state = [Kuala Lumpur, Penang, Pulau Pinang, Johor, Selangor]` and
  `type = [condo]`, the same markets and product as the New Project tabs,
  because the two databases are two halves of one catalogue. A bare
  `/manage/property/catalog/subsale` therefore does **not** list the whole
  catalogue, and a count read off it is a count of that working set. Both
  dimensions are marked `defaulted` so the UI can show they are in force. Two
  more filters are not obvious: the provider filter
  matches only **active** sources (a stale source keeps its identity but
  supplies no values, so it must not answer "which projects does PropertySifu
  feed"), and the segment filter ORs `whereJsonContains` per value because
  memberships are a multi-valued SET — a dual-segment row must match either
  chip. Provider counts come from `catalog_project_sources` with
  `COUNT(DISTINCT catalog_project_id)`, since one row can legitimately count
  under several providers.
- ⚠️ **Three AJAX routes under the catalogue prefix belong to ANOTHER screen.**
  `GET /manage/property/catalog/search` (`CatalogController@search` →
  `EdgePropLayoutService::searchCondos`, federated),
  `GET /manage/property/catalog/layouts` (`@layouts` → `deriveLayouts`) and
  `GET /manage/property/catalog/developers/search` (a compatibility alias for an
  already-deployed bundle) are called only by the working-project create modal in
  `Pages/Manage/FacebookLeadGenerator/Projects/Index.vue`. Renaming or deleting
  them breaks a page outside this module, and nothing in the catalogue UI would
  show it. `layouts` casts its id to `(string)` on purpose — **never `int`** —
  because the picker may hand over a uuid, and a uuid cast to int becomes 0.
- **Catalogue form editors — no raw JSON is ever typed.** Every JSON column is
  edited by the SHAPE its data actually has: `facilities` / `virtual_tours`
  are chip lists (`Components/ChipsInput`, comma or Enter commits a badge);
  `sale_process` and `name_translations` are pair lists
  (`Components/PairsInput`, array-of-rows vs locale-keyed object); the six
  provider payloads are labelled row tables (`Components/StructuredRowsInput`)
  covering the three storage shapes the catalogue really uses — `rows`
  (`asking_*`, `rental_yield_detail`), `keyed-rows` (`quarterly_*`, keyed by
  quarter) and `grouped-rows` (`amenities`, keyed by category). That component
  **preserves any key a scraped row carries that has no column**, so opening a
  provider row in the form can never drop fields, and it stores numeric
  columns as numbers (the analysis engine compares them).
- **Every structured column travels as ONE JSON document — a size limit, not a
  style choice.** `CatalogProjectFormModal` `JSON.stringify`s each of
  `JSON_FIELDS` + `ENCODED_FIELDS` (the six provider payloads, plus
  `market_segments` / `facilities` / `virtual_tours` / `sale_process` /
  `name_translations` / `developers` / `floor_plans`) before submitting, and
  `StoreCatalogProjectRequest::prepareForValidation()` decodes them back
  before any rule runs. Posted leaf by leaf a provider-fed record is **~1300
  multipart parts** (4 per amenity POI, 11 per asking listing, 21 per floor
  plan) and PHP discards everything past `max_input_vars` — default **1000** —
  with no warning, no error and no exception. What fell off the end first was
  the trailing `_method=put`, so the edit submit came back **405 Method Not
  Allowed** and nothing saved; collapsed like this the same record posts ~60
  parts. Decoding is `is_string`-guarded, so a caller posting real arrays is
  unaffected, and a malformed document is left as the raw string so its
  `array` rule fails against that field. Three rules hold this together:
  **`media_*` are never encoded** (they hold File objects, and
  `JSON.stringify(a File)` is `{}` — the upload would vanish silently);
  **`_method` stays LAST** so any future blow-up is a loud 405 rather than a
  truncated body routed into `update()`, where dropped keys become nulls that
  the repository then PINS into `manual_overrides` beyond any sync's reach;
  and **each `syncs_*` flag stays after the list it licenses** (see the next
  bullet) so a cut deep enough to reach the rows also removes the flag and the
  delete sweep never fires. The request also re-applies `TrimStrings` /
  `ConvertEmptyStringsToNull` *inside* each decoded document, which the global
  middleware can no longer reach — without it an untouched cell would start
  storing `''` instead of `null`. A value that is ALREADY a string is passed
  through unencoded rather than wrapped again: some rows are double-encoded at
  ingestion (the reason `safeJsonArray()` and `getAmenitiesAttribute()` exist),
  the `array` cast then hands the form a string, and re-wrapping it would fail
  `nullable|array` and make the whole record unsavable over a panel that
  renders empty — the request peels a second layer instead, so saving REPAIRS
  such a row.
- **Two consequences of typed documents, both intended.** A document carries
  types, so numeric cells stop being stored as multipart's strings — the same
  column no longer holds `"750.99"` from the form and `750.99` from a sync.
  That also **narrows `manual_overrides`**: pinning is driven by
  `getDirty()` (`CatalogProjectRepository::update()`), and while every scalar
  arrived as text these JSON columns were dirty on EVERY save and pinned
  whether or not the admin touched them. They are now pinned only when
  genuinely edited, which is what the repository always claimed to do — but if
  anyone was opening a record and re-saving it to freeze it against
  `catalogue:sync`, that habit has stopped working. Pin deliberately instead.
- **Admin floor plans & media** — the two children a row needs before it can
  publish, and which a hand-created project has no listings to derive. Both
  are part of the project form itself (nested `floor_plans` rows, and one
  `ImageDropzone` per media kind posting `media_{kind}[]`), so one pass makes
  a project publishable; the form sends `syncs_floor_plans` to declare it owns
  the plan set, because multipart cannot express an empty array and a bare PUT
  would otherwise read "no plans submitted" as "delete them all". They also
  keep standalone endpoints for the Show page:
  `POST|PUT|DELETE {id}/floor-plans[/{planId}]`
  (`CatalogFloorPlanRepository`, fields =
  `CatalogFloorPlan::ADMIN_EDITABLE_FIELDS`) and
  `POST|PUT|DELETE {id}/media[/{mediaId}]` (`CatalogMediaRepository`).
  ⚠️ **Deleting catalogue media has a cross-schema rule of its own** (2026-09-14).
  The bucket object goes first and OUTSIDE the transaction (a bucket delete
  cannot be rolled back, and a stranded object is recoverable where a row
  pointing at a deleted file is not). When that storage delete FAILS,
  `MediaService` records a durable `DELETE_INDEXED` intent — but its retry job
  only ever searches the **site** `media` table, so it can never reach a media
  row living in the catalogue schema (`MasterMedia`, which is where a master
  project's media row is). So the repository asks
  `MediaService::hasDurableDeleteIntent()` and, only on a CONFIRMED intent,
  force-deletes that row alongside its `catalog_media` row — otherwise the row
  would outlive the object the retry is about to delete. With no confirmed
  intent the row is the object's only remaining trace and is deliberately KEPT.
  `create()` follows the same rule on its rollback path: the `catalog_media`
  insert rolls back, but the `MasterMedia` row written before it does not (it
  is outside that transaction), so it goes with a confirmed intent too.
  Background: [shared/media/readMe.md](/docs/modules_handbook/shared/media/readMe.md).
  Admin-owned plans are **pinned in the plan's `manual_overrides`** —
  `syncCatalogFloorPlans()` deletes plans no derived layout matches unless
  `hasRetainedDependencies()` holds, which a non-empty `manual_overrides`
  satisfies; without the pin a hand-typed plan vanishes on the next
  re-derive. Media follows the one-representation rule: an uploaded file via
  `MediaService` (`collection: catalogue`, previewed through a per-request
  signed URL, never persisted) or an external `url` — never both, never
  neither. Admin media carries no `catalog_project_source_id`/`source_key`,
  so a provider rerun's source-keyed upsert cannot claim it; deleting an
  uploaded row removes the bucket object via `MediaService::delete()` before
  the row.
- **Developer directory and assignment** — one canonical company is created
  once on `/manage/property/developers`, with exact aliases, website, active
  state and a complete ordered set of directional `developer_relationships`.
  An alias is another spelling of the SAME company; a subsidiary, brand,
  landowner, related company or joint developer is a separate `developers`
  row joined by a typed relationship. `DeveloperRepository` writes the master
  and relationship set transactionally, while `Developer` takes the same
  global identity lock used by ingestion so an admin edit cannot race an
  imported name/alias.
  - The Catalog Project form first searches for one primary developer. Only
    then does `GET /manage/property/developers/{id}/relationships` expose its
    configured optional companies; choosing none is valid.
  - The selected project-specific set still writes the EXISTING
    `catalog_project_developers` pivot. The first row is `developer`; every
    later role is derived server-side from `developer_relationships`, so a
    crafted client cannot attach an arbitrary company or relabel a brand as a
    joint developer.
  - Migration `2026_08_16_100001_create_developer_relationships_table.php`
    backfills every existing multi-developer project's primary → secondary
    pairs, preserving historical assignments when the stricter picker goes
    live. There is still no `developer_id` column on `catalog_projects`.
  - The retiring `catalog_projects.developer` text is updated only as a pinned
    compatibility projection; older API callers posting that text still take
    the legacy text-to-pivot path during the transition.
- **MY new-launch crawls** — `catalogue:scrape-edgeprop` /
  `catalogue:scrape-iproperty` fetch EdgeProp.my + iProperty.com.my new-launch
  pages through ScraperAPI (`SCRAPERAPI_KEY`, ultra_premium — every request
  costs credits; crawls are manual and never scheduled), cache every
  `__NEXT_DATA__` node for credit-free resume, and assemble one inbox
  artifact; providers **`edgeprop-nl`** and **`iproperty`** ingest it via
  `catalogue:sync` (adapters extend `NewLaunchArtifactAdapter`). An empty
  listing page BEFORE the portal-reported total is treated as a transient
  unrendered shell — never cached, run stops partial, the next run refetches;
  only a second empty fetch of the same page confirms a real early end.
  EdgeProp quirk (2026-08-05): its DEFAULT 12-row paging stops serving rows
  past `?start=240`, but the endpoint honours `?size=` — so discovery is one
  `?start=0&size=3000` request returning the whole ~2,780-row catalogue
  (a multi-MB `__NEXT_DATA__` blob; `NextData` slices it by offset because a
  capturing regex would blow PCRE's backtrack limit and misread the page as
  a bot block). A provider
  code separate from `edgeprop` is load-bearing (identity is
  (provider, external_id) — one code would let the crawl overwrite the file
  feed); `meta.snapshot === 'full'` in the artifact double-guards `--full`
  deactivation, so a partial crawl can never deactivate sources. iProperty
  prices are official-when-disclosed and win `price_min`/`price_max` in
  `field_priorities`; EdgeProp marketing prices are PSF-derived and flagged
  `price_derived` in the plan contribution's `source_fields`. Cross-portal
  identity: in-adapter `resolveCanonicalId` (PropertySifu's exact slug+name /
  name+5dp-coordinates rule) plus `catalogue:my-map-sources`, which reports the
  same `CatalogueMergeCandidateService` proposals the Duplicates to merge tab
  reviews (`--apply` gated by `my_auto_attach_enabled`) — CLI and screen must
  never disagree about what counts as a candidate. `ProjectNameKey::variants()`
  treats a bracketed segment as an ALIAS ("M Grand Minori (Residensi Anggun
  Mewah)" also keys as "M Grand Minori") but NOT when it reads as a qualifier
  (`phase`, `block`, `fasa`, `tower`, …): "Taman Penaga (Phase 2)" and
  "(Phase 4)" are real neighbours, and since a merge deletes a canonical the
  rule errs toward missing a match.
- **Scraped review** — the New Project Database: per-provider `?tab=` views over
  the source layer
  (`edgeprop-nl` · `iproperty` · `propertysifu` · Combined-by-`catalog_project_id` with
  server-computed conflicts + best price); provider codes in
  `config('project_catalogue.scraped_review_providers')`. The source tabs stay
  read-only — editing stays in the catalogue admin — but two further tabs act on
  CANONICALS and do write:
  - **`?tab=duplicates` — Duplicates to merge** (the old `?tab=combine` key still forward-maps). One row per DEVELOPMENT the evidence
    says is held more than once, from `CatalogueMergeCandidateService`
    (alias-aware `ProjectNameKey` + ≤300 m). Two collapses, for different
    reasons:
    - a duplicate surfaces once from *each* portal's side, so raw proposals
      would ask for the same approval twice — deduplicated by unordered pair;
    - **a development held as N canonicals produces N(N−1)/2 pairs**, so KL48's
      three records produced three rows that CONTRADICTED each other: ranking
      runs per pair, so the middle record was the keeper in one row and the
      absorbed record in another, and taking any one row invalidated the rest.
      `cluster()` unions the edges (union–find) into connected groups and emits
      one row per group — "same development" is transitive, and a building
      cannot be partly the same building. `distance_m` becomes the WIDEST gap in
      the group ("all within 20 m"), the claim the row must stand behind.
      `absorbed_records` carries every member; `absorbed` is kept populated with
      the best single one so a stale JS bundle still renders truth (see the
      PHP-live/JS-stale rule under Publication). The richer record (floor
    plans → hero → source count → older id) survives. **Provider badges show
    only `scraped_review_providers`** — this tab IS the New Project Database, so
    badging the subsale file feed and the legacy snapshot beside the launch
    feeds implied KL48 had four new-project sources when it has two, and made
    the tab disagree with the review page it opens. Ranking still counts EVERY
    source: the merge deletes the other record and the survivor's id keeps any
    inbound links, so more evidence of any kind is the better survivor. **Its Review action opens
    the survivor's review page**, which is where combining is decided and
    applied; this tab is the queue, not the decision. The per-pair modal that
    used to own it (`CombineMergeModal.vue` + `scraped/combine/*`) is gone: a
    record can have MORE than one pending partner — KL48 has two — and a screen
    built around one pair cannot express that, nor show the market and rental
    figures the same decision depends on. Two rules it taught, both still
    enforced on the review page:
    - **One field set, not two.** The modal began with a private 14-entry list,
      so a merge could neither show nor decide a record's pricing, segments,
      coordinates or facilities — a Stellar Damansara pair displayed 6 rows
      while differing or filling on 17, hiding a RM 1.9m–3.4m launch range.
      Two screens answering the same question about the same record must not
      know different amounts about it. That is now structural: there is only
      one screen.
    - **Every provider tab carries the same two decisions per row**, so triage
      does not require opening each record: a **Duplicate** column and a
      **Publish** column, both immediately after the project name (a wide table
      scrolls, and a decision column the reader must hunt for is one they will
      not use). `mergeState()` resolves three cases — *pending* (evidence says
      another record is this development; the badge IS the link, and it targets
      the **survivor's** review page because `pendingMerges()` filters on
      `survivor.id`, so an absorbed row linked to its own page would show
      nothing to merge), *merged* (the canonical already carries more than one
      CRAWL feed — derived from those feeds, never a stored flag, so it cannot
      go stale), and *single*. **Count distinct CRAWL feeds, not
      distinct providers:** `edgeprop` is the subsale market FILE, not a launch
      crawl, so a development that both launches and already trades on the
      secondary market carries `edgeprop` + `edgeprop-nl` as a matter of course
      — one record with two kinds of evidence, not two records someone
      combined. That shortcut made 1,169 of 3,184 rows claim "Merged" while
      exactly one (Puteri Ariana) had been. The subsale feed still shows, as its
      own *also in Subsale* pill: market context, never a merge signal.
      A pending badge names the DATABASE the other copy sits in
      (`duplicateLocation()`), not the feed that wrote it: 78% of pending merges
      (157 of 201) are a launch-crawl record duplicating a **subsale** record,
      and the raw provider name rendered that as the bare word "EdgeProp" —
      indistinguishable from "EdgeProp New Launches" on the very tab the reader
      is standing in. A New Project duplicate still names the portal, because
      there it is the distinguishing fact. The badge also says what combining
      WINS (`unlocks publish`), since a merge that unblocks publication is a
      different errand from a tidy-up.
      `publishState()` adds *published* / *ready* / *not published*, where
      *ready* links to Review because an unpublished record that already meets
      every requirement is a decision waiting on a person. Both reuse
      `cachedPairs()` and `publishReadyIds()`, which the tab badges already
      compute on every render — no new query per row. The review screen's
      apply/publish paths already `forget()` both caches, so the table reflects
      a merge as soon as it happens.
    - **Both decision cells are ONE LINE, and say nothing when there is nothing
      to do** (2026-08-09). They were three multi-line columns — Duplicate as a
      four-line card, plus Publish and Review panel — which cost a paragraph per
      row on a table already wide enough to scroll. Two things fixed it.
      *First, Publish and Review panel merged into one `publication` chip:* they
      were two views of ONE lifecycle (not-ready → Ready → in review *n*/2 →
      awaiting an approver → sent back → live) and a record is only ever at one
      stage, so whichever column was not carrying it printed a redundant word on
      every row. *Second, the common case became an em dash.* On a typical
      iProperty page 20 of 25 records have no duplicate, 19 are not ready and 25
      have never been submitted — spelling each out buried the handful that need
      a person. The dash is the same "nothing here" the table already uses for a
      missing developer. Nothing was deleted: every sentence those cells used to
      print (which records duplicate this one, which database they sit in, the
      distance, what merging wins, every seat and note on the review) is in the
      cell's `title`, which is where *why* belongs when the reader is scanning
      hundreds of rows for *what*. Two rules held while compressing: a colour is
      never the only carrier of meaning (a merge that unblocks publication also
      says the word `unlocks`), and on the Published tab — where the column asks
      *who* released it, not *whether* it is out — a record with no panel behind
      it renders a dash via `approver-only`, because "Live" repeated down 25
      rows is a column of identical words pretending to be information.
    - **One way into a record, not three** (2026-08-09). The per-row expander
      went with the same edit: it fetched `scraped/{sourceId}/detail` to show
      the raw scraped payload inline, which is a second, worse rendering of what
      the catalogue record itself shows properly. In its place the WHOLE ROW is
      a click target for its canonical (`row-href`), and the Actions column
      carries **Open source page** (the portal it was scraped from) and **Edit**
      (`?edit=1`, the same convention every other index's row Edit uses,
      GUIDELINES §14). The inline *Catalogue* text link under the project name
      went too — with the row itself clickable and an explicit Edit button, a
      third door to the same page was just ink. `DataTable`'s row click already
      ignores anything landing on a real interactive element, so the thumbnail,
      both chips and both actions keep their own destinations. The **Duplicate**
      tab is the one view with NO row link: its row is a cluster of records, so
      "the record this row is about" has no single answer — it keeps its own
      Review button, which names the survivor explicitly.
      *Left orphaned by this and safe to delete when convenient:*
      `CatalogScrapedController::detail()`, its
      `scraped/{sourceId}/detail` route, and
      `Catalog/Scraped/Partials/ScrapedSourceDetail.vue`.
    - **Fields the sources agree on are shown, not dropped.** They are not
      decisions, but "these agree on 8 other fields" is what makes approving a
      merge reasonable. Coordinates are not excluded either — the shared
      comparison treats them as equal within ~55 m, so the metres-apart
      geocoding that blocked auto-merge reads as agreement rather than a
      decision nobody can act on.
  - **`?tab=publish` — To Publish.** Crawl-fed canonicals that already satisfy
    the publication contract and are still unpublished, scored through
    `CatalogueCompletenessService::reportMany()` (the batched path, so the tab
    cannot drift from the publish button's own gate) and cached. Its row action
    is **Review**, not Publish — nothing goes live without passing the screen
    below. A record already in front of the panel STAYS in this queue (it is
    still unpublished) and its **Publication** column says so — `1/2` rather
    than `Ready` — which is what stops a second submission of something already
    in flight.
  - **`?tab=in-review` — In Review.** Every round the panel has not finished
    with, and the ONLY view where the four seats are columns rather than a chip
    — *Submitted by · Cold-eye reviews · Approver · Waiting on* — because here
    the panel is the subject rather than an attribute of a record. Rows come
    from `inFlightIds()`: the LATEST round per project with status pending,
    awaiting-approval or changes-requested. Latest-round-only is load-bearing —
    a project rejected once and later published still has that rejected round
    in its history, and a list built from raw statuses would show it as
    outstanding forever. Approved and withdrawn rounds drop out (approved ones
    surface on Published).
    - **It is not scoped to the crawl providers** the other tabs filter on. A
      reviewer is asked to look at a RECORD, not a feed; scoping would silently
      drop a hand-created project from the one list that exists to say what is
      outstanding.
    - **Rows waiting on the READER sort first**, whatever the sort column, and
      carry the brand "Your turn" chip. One list therefore serves both "where is
      everything" and "what needs me" — the personal queue this replaced was a
      second tab to check, and the thing every reader actually wants from a
      shared worklist is their own name. Then longest-waiting first. (The
      correlated subquery on `submitted_at` this used to describe was removed at
      the master-catalogue cutover — read `CatalogScrapedController` for the
      current ordering rather than trusting a remembered shape.)
    - **The tab strip carries the personal count from every other tab**: the
      badge accents and gains a `N you` marker when rows are waiting on the
      reader, and hands off automatically — Boon and Ke Xin carry it while the
      cold-eye reviews are open, and it moves to Zen the moment both are in.
      Without that, a reviewer standing on a provider tab has no way to learn
      the panel needs them short of remembering to look.
    - The row opens the **review screen**, not the record: on this tab the row
      is about the ROUND, and the review screen is the only page that can move
      it. `Submitted by` is keyed `submitted_at` because DataTable sorts by the
      column KEY and only `name` / `submitted_at` are whitelisted server-side —
      a `submitted_by` key would render a header that looks clickable and
      silently does nothing.
    - **Inline person rows** (2026-08-22, the funnel Leads tab's style; also on
      Published): *Submitted by · Reviewer · Approver*, one pick per row,
      directly on the page rather than in the drawer. Counts are **faceted**
      (each row counted with the other rows' picks applied) so a pill never
      promises rows another pick has removed; options come from the panel PLUS
      anyone seated in the candidate rounds, so someone who left the panel
      stays filterable on their historic rounds. Server-side via
      `CataloguePublishReviewService::seatIndex()` (the bare seat map of each
      project's latest round) and `CatalogScrapedController::publicationPeople()`;
      narrowing is by **id set, never a join** — rounds are site-side, the rows
      query is catalogue-side. The picks (`?submitted_by=&reviewer=&approver=`)
      ride `preserveParams` like `tab`, and the tab badges deliberately ignore
      them — the badge answers "how big is the tab", the All pill answers "how
      big with my picks cleared". On Published the All total includes
      pre-process records (published before 2026-08-09) that no person pill can
      ever count.
  - **`?tab=published` — Published.** The far end of the same pipeline:
    crawl-fed canonicals now live on the public site, newest release first.
    No decision columns — there is nothing left to decide — but it carries the
    **Approved by** column, which is the only place the panel that released
    each record can be read back. Records published before the review process
    existed (2026-08-09) show a dash — nobody approved them, and the tooltip
    says so; that is history, not a gap. Its count and its rows come from one
    shared `publishedQuery()`, so the
    badge cannot disagree with the list under it.
- **Pre-publish review** — `GET|POST /manage/property/catalog/{id}/review`
  (`CatalogReviewController`, `Pages/Manage/Property/Catalog/Review.vue`).
  Reached from the Combine row action, the To Publish row action **and every
  record's Show page**: a record still missing a floor plan never enters the
  publish queue, so a queue-only entry point made the review unreachable for
  exactly the records that most needed it. Answers three questions.
  - *Is this the same development as something else?* `pendingMerges` lists
    every record the candidate service proposes merging INTO this one (only
    pairs where this record is the SURVIVOR, or two screens would propose
    opposite merges of one pair). Their sources are shown as ordinary comparison
    columns flagged `pending`, because that is what they are about to become.
    Posting `combine[]` applies them: sources move via `attachSource()`, each
    pair re-derived at apply time so a stale page cannot merge what a sync has
    moved, and the emptied record is deleted by the existing orphan rule behind
    a `ConfirmModal` naming it.
    **Including pending sources is a correctness rule, not a display choice**: a
    pending source that disagrees becomes a `conflict` the admin must settle and
    which is then pinned. Without it, combining hands the field back to
    `field_priorities` to re-resolve, so the value on screen before the merge is
    not necessarily the one published after it.
    Combining runs BEFORE the field choices resolve, so a choice may name a
    source that is still moving across.
  - *Where did each published fact come from?*
    `CatalogueSourceComparisonService` lays every active source's RAW scraped
    `fields` value beside the resolved canonical one, per field, grouped
    (identity / location / classification / development / pricing / amenities).
    Deliberately wider than the combine chooser's 14 columns — that one asks
    "are these two canonicals one development", which is a settled question by
    the time a row reaches the publish queue. Rows where the sources hold
    different values are `conflict` and are the admin's to settle; a value only
    one source carries is not a decision. Two rules the data forced: `developer`
    is compared against `primaryDeveloperName()` because the raw column is null
    on most records (the pivot owns it), and coordinates agree within ~55 m
    (`COORDINATE_TOLERANCE`) or every record would report two conflicts nobody
    can act on. A choice is pinned into `manual_overrides` — same reasoning as
    the combine chooser — and only conflict rows are posted, so agreeing with
    the feeds never freezes a field against future syncs. A choice naming a
    source carries a **source id, never a value** — re-read server-side, so a
    tampered form can only pick between values a provider actually reported for
    that record.
  - *What if every source is wrong?* Any field may also be overridden with a
    typed value (`choices[field] = 'custom'` + `values[field]`), including
    fields the sources agree on — two portals agreeing does not make them
    right. Three rules this needs:
      - **The input follows the column** (`FIELD_TYPES`): number, date,
        textarea or text. The server re-coerces and DROPS what will not cast —
        MySQL turns `"about 800"` into `0` rather than rejecting it, so an
        uncoerced entry would publish as a fact.
      - **`list` fields are never typed over.** `facilities`, `amenities` and
        `market_segments` hold arrays and the cell shows a SUMMARY
        ("24-hour security, Badminton hall +9 more"); saving that string would
        replace an 11-item list with its own caption. They route to the
        catalogue form's chip/JSON inputs, and the Form Request strips the keys
        even if a crafted payload offers them.
      - **The seed value is `raw`, not the display string**, or editing a
        truncated cell would save the truncation.
  - Choosing or typing a **developer** re-syncs the developers pivot
    (`DeveloperIdentityService::syncProject`), exactly as
    `CatalogController::update` does. The text column alone is
    display-invisible — every reader and the completeness contract resolve the
    pivot — so an unsynced write looks like it silently did nothing.
  - *Is it worth publishing?* The page embeds the REAL public detail page at
    `?tab=invest&embed=1`, so market value, comparables, rental prediction and
    cashflow are read as a customer will read them before the publish click. An
    iframe rather than a re-implementation: the analysis UI is a dozen
    components fed by five endpoints, and a copy would drift from the very thing
    being checked. `embed=1` hides the site nav, language switch, footer and
    hero (`SiteLayout`'s `hideHeader`/`hideFooter`) — inside a frame that chrome
    pushes the analysis off screen. It is a DISPLAY flag only: nothing about
    what the page may show turns on it.
    Layouts are listed with their plan drawings, because reviewing a layout
    without seeing it is reading dimensions and guessing the rest. Each card
    says whether it **publishes** or is held back as a duplicate: the catalogue
    keeps a row per source contribution while the page publishes the
    deduplicated official set, so KL48 carried 5 rows for 3 real unit types.
    `?plan={id}` on the Show page opens that layout's editor straight away —
    linking to the tab alone made the reader re-find the row they had just
    clicked.
  - **Only New Project providers are compared.** Columns and layouts are
    filtered to `config('project_catalogue.scraped_review_providers')`
    (edgeprop-nl · iproperty · propertysifu). `edgeprop` is the SUBSALE market
    file and `petav2_legacy` is compatibility evidence only — neither carries
    launch facts, and offering them as peer columns invited picking a launch
    value from a feed that has none (KL48 showed petaV2's generic "2B"/"3B"
    beside the developer's own A/B1/B2). Two guards make the filter safe: it
    falls back to ALL sources when it would otherwise empty the screen (the
    Review button also sits on subsale records), and a layout with no source
    contributions at all is admin-owned and always kept.
- **Multi-key layouts** — `catalog_floor_plans.key_bedrooms`, an ordered list of
  bedroom counts (`[0, 2]` = studio + 2-bed) declared per plan in the floor-plan
  form's **Keys** section. A key is a separately lettable sub-unit behind its own
  door, and it cannot be inferred from `bedrooms`: an 850 sqft dual key reported
  as "3 bedrooms" lets as a studio AND a 2-bedroom, to two tenants, whose rents
  add up to well over one 3-bedroom's.
  - **`is_dual_key` is DERIVED from this, never posted.** It predates the
    composition and is only a yes/no label, but the New Supply breakdown reads
    it — so two inputs for one fact would drift the moment someone edited the
    keys and left the tick alone, putting a wrong number on a public page. The
    Form Request computes both; the old checkbox is gone.
  - **Fewer than two keys stores as null**, so an ordinary layout never carries
    a "breakdown" of itself or reads as a dual key.
  - **Rental analysis prices each key on its own bedroom count and sums them**
    (`ProjectInvestmentWholeUnitRental`), unadjusted for size — the same basis
    the single-key path uses, so the two are read alike. The combined figure
    feeds cashflow and gross yield; `ProjectDetail`'s `investmentRental`
    repeats the sum so the Summary headline cannot disagree with the Rental tab
    explaining it. A key with no nearby comparable contributes zero and says so
    — silence there would read as a weak market rather than a missing one.
  - Adding a column here means **three** allowlists, not one: the model's
    `$fillable` + `ADMIN_EDITABLE_FIELDS`, and BOTH `data_all`/`data_only` lists
    in `CatalogFloorPlanRepository`. Miss the repository and the write is
    silently dropped with no error anywhere.
- **Publication** — `CatalogueCompletenessService` gates
  publish/unpublish; `catalogue:completeness` reports per market;
  `CatalogueDetailService` + `GET /{country}/projects/{slug}` serve the
  **Malaysian** detail payload (published-only public, VIEW_PROJECTS preview,
  locked analysis flags, preset-to-locale routing). ⚠️ **It is not one shared
  payload**: `ProjectDetailController::show()` branches on the country BEFORE
  `CatalogueDetailService` is ever reached — HK goes to `BuildHkProjectDetail`,
  UAE to `BuildAeProjectDetail`, each with its own tabs and component tree. All
  three resolve the slug through `CatalogueFederationService::findBySlug()`, so
  the federation story holds; the payload builder does not. Rules worth knowing:
  - `publishIfComplete()` is the CONTENT gate, in one place, and there is now
    exactly one caller: the approver's sign-off
    (`CatalogPublishReviewController::recordApproval`). The two former doors are
    shut — `CatalogController::publish` redirects to the review screen instead
    of publishing, and the review screen's own `apply` no longer takes a
    `publish` flag. Both were left mapped rather than deleted because PHP goes
    live on the dev box the instant it is saved while the built bundle still
    posts to the old endpoint until the next build.
  - ⚠️ **"One caller" is true of the UI only — two CLI publishers sit beside
    the gate and skip it entirely.** `catalogue:ae-publish-parity` and
    `catalogue:hk-publish-parity` each build an id list and call
    `CatalogProjectRepository::publishMany()`, a bare
    `update(['published_at' => now()])` with **no** completeness check and **no**
    review record. They exist to reach parity with a reference site, where the
    upstream feed is the authority rather than our contract. So a published row
    does not prove `publishIfComplete()` ever passed for it, and a completeness
    bug will not show up on the rows those two commands published.
  - ⚠️ **The publication review round has ZERO automated coverage.** Nothing
    under `tests/` references `CatalogPublishReview` or `catalog_publish_reviews`
    — and the review sign-off is the ONLY route to publishing from a screen. The
    map's §6 tells readers that `tests/Feature/Console/` and
    `tests/Feature/Property/` are "the behaviour that is actually guaranteed";
    for this flow, nothing is. Change it carefully and by hand.
  - ⚠️ **There is a fully AUTOMATIC unpublisher, and it runs on every resolve.**
    `CatalogueCompletenessService::enforcePublishedCompleteness()` is the last
    statement of `ProjectCatalogueMergeService::resolveWithinTransaction()`: if a
    record is published and its completeness report says incomplete, it is
    unpublished on the spot. So it fires on every ingested record of a
    `catalogue:sync`, on the stale sweep's re-resolve, on `catalogue:import`,
    and on six admin write paths (project update, overrides, floor-plan delete
    and merge, media update and delete). **The asymmetry is what bites:** the
    admin paths flash "…automatically unpublished — missing: …", while a sync
    run reports only `sources_deactivated` / `media_deactivated` and counts no
    unpublishes at all — a project can leave the public site overnight with
    nothing to show for it but a null `published_at`. What usually trips it:
    `market_segments` recomputed empty from active sources, hero media
    deactivated because the feed stopped listing it, or a full-snapshot sweep
    deactivating a source and pruning its plans so `hero_media`, `floor_plans`
    and `developer` all fail at once. Run `catalogue:completeness` before
    blaming the page. The nightly six-provider sync is gated on
    `project_catalogue.sync_enabled`, which **ships FALSE** — so today the
    trigger is a hand-run sync, and flipping that flag makes this an
    unattended nightly path.
  - **Unpublishing BY A PERSON stays a one-person action.** Taking something DOWN is a
    correction, and holding a correction behind three signatures leaves a bad
    record public while they are collected.
  - The page's JSON endpoints (`analysis`, `unit-rental`, `supply`, `agents`,
    `amenity-demand`) honour the SAME preview rule as `forSlug()` via
    `previewableProject()` — published for everyone, plus unpublished for
    `VIEW_PROJECTS`. Until they did, an unpublished preview rendered the shell
    with every analysis dataset 404ing, which made the review above impossible.
- **Master catalogue connection** (2026-08-18) — the catalogue family
  (`catalog_*` canon, `developers`, `market_*` reference, master `media`) lives
  on the **`catalogue` connection** (`CatalogProject::CONNECTION`), resolved by
  one env variable, `CATALOGUE_USE_DEFAULT_CONNECTION` — set, it collapses onto
  this deployment's own database; absent, it reads the shared `master_projects`
  schema live. **Tests always collapse** (`phpunit.xml` forces the flag on).
  ⚠️ **OPEN — which mode PRODUCTION runs is stated two different ways and code
  cannot settle it.** This line used to assert "dev and tests resolve to the
  local database and production resolves to the shared `master_projects`
  schema"; [databases-and-distribution.md](/docs/modules_handbook/shared/project-catalogue/databases-and-distribution.md)
  §2 asserts the opposite ("Production runs COPY"). `config/database.php` is
  purely env-driven, so only production's `.env` can answer it — check the box
  rather than either document, and correct both (and the `CatalogProject.php`
  class comment, which repeats the old wording) once you know. Nor is "dev
  resolves to the local database" true of every dev box: a checkout with no
  such line reads the master LIVE, which is the only configuration that
  surfaces the cross-database bugs §6 of that file lists.
  ([databases-and-distribution.md](/docs/modules_handbook/shared/project-catalogue/databases-and-distribution.md)
  §2 covers the two modes. It replaces the
  `docs/planning/master-catalogue-shared-database-plan.md` this line used to
  cite, which was never committed.) The rules
  that keep it correct: **never JOIN across the catalogue and default
  connections** (pluck keys from one side, `whereIn` on the other); a LOCK is
  only real inside a transaction on its OWN connection (see the
  dual-transaction shapes in `attachSource`, the ingestion sweep, the snapshot
  and review-submit repositories); relations from a catalogue model to a site
  model stay site-side via `Concerns\KeepsRelatedConnections` — do not remove
  that trait from a pinned model. Site-side by design: analysis
  snapshots, VR bakes, publish reviews, `site_catalog_projects`, legacy
  crosswalks — but NOT sync runs: `catalog_sync_runs` is catalogue-side, since
  `market_*.catalog_sync_run_id` references it (provenance travels with the
  data). Validation rules on catalogue tables carry the `catalogue.` prefix
  (`exists:catalogue.catalog_projects,id`); working-project search/ordering
  over canonical facts is FEDERATED in `Src\Property\Project`
  (`matchingCanonicalIds` + a bounded id→name CASE), never a subquery into
  `catalog_*`. Catalogue-owned uploads are `Src\Common\MasterMedia` rows;
  site uploads stay `Src\Common\Media`.
- **Federated reads — master + this platform's own projects** (2026-08-18).
  `App\Services\Property\CatalogueFederationService` is how every catalogue
  surface reads: it asks the SAME question of the shared master and of this
  site's own `catalog_projects`, deduplicates by **uuid** (master wins — a
  local row carrying a master uuid is a frozen mirror kept for foreign-key
  resolution, not a second project), and sorts/pages in PHP because two
  databases cannot be UNIONed. Consumers: the admin catalogue index + `show`,
  the working-project picker (`EdgePropLayoutService::searchCondos`), the
  public listing (`BuildNewProjectListing` — chips, totals, cards, country
  tabs), the public detail page (`ProjectDetailController` +
  `CatalogueDetailService`) and the sitemap. Three rules worth knowing:
  - **`local()` returns NOTHING in single-database mode** (dev, tests). One
    database means master() already has every row; asking twice doubles every
    COUNT while the uuid dedupe keeps the list looking right.
  - **Slug resolution is local-first** (`findBySlug`), the only place the
    platform's own row outranks the master: a site that slugged its own project
    means that URL to point at its project.
  - **A platform's own project is not a different model** — it is a
    `CatalogProject` read on the site connection, so its floor plans, media and
    developers follow it into the site database
    (`Concerns\KeepsRelatedConnections`). Repositories create children on the
    PARENT's connection; `catalog_projects.origin` (`own`/`mirror`) is what
    tells the two kinds of local row apart, and `catalogue:offset-local-ids`
    keeps local ids a billion clear of the master's.
  - **Reading the catalogue FROM a site row is federated too** (2026-09-14).
    A site table stores only an integer `catalog_project_id`, and the same
    integer can exist in both databases — a site keeps frozen MIRRORS of master
    rows under the master's own ids. So the ownership question is never "does
    the site have this id" but **"does the MASTER lack it"**:
    `CatalogueFederationService::siteOnlyReferenceIds()`. Built on it:
    `referencedRows()` (ask each owning database the same question, concatenate),
    `matchingReferenceIds()` (the `whereHas` replacement — apply the answer as a
    plain `whereIn`) and `isListedOnSite()` (the single-row listing check the
    detail pages use). Full contract in
    [databases-and-distribution.md](/docs/modules_handbook/shared/project-catalogue/databases-and-distribution.md)
    §6.2. Consumers fixed to use them: `Project::floorPlans()` (now a
    `Src\Common\Relations\FederatedHasMany`), the new `Project::floorPlanCounts()`
    for lists, `Project::matchingCanonicalIds()` and
    `scopeOrderByCanonicalName()` — so a working project linked to a
    platform-created catalogue row is finally found by `searchCanonical`,
    sorted by its canonical name and shows its floor plans — plus
    `LeadsController::buildFloorPlanOptions()` (the booking form's unit-type
    picker, which was silently empty for those projects),
    `ProjectDetailController::approvedVrPanorama()` and the RentalEstimate
    submission search.
  - ⚠️ **`withCount('floorPlans')` on a working `Project` is NOT federated.**
    A count aggregate compiles a correlated subquery on the PARENT's
    connection, which the relation's own federation cannot reach. Nothing calls
    it today (the only `withCount(['…','floorPlans'])` is on `CatalogProject`,
    a same-connection catalogue-family read). For a list, use
    `Project::floorPlanCounts()`, which runs at most two grouped `COUNT`s — one
    per database — and never joins across the boundary.
- **Site listing layer** (2026-08-18) — `sites` + `site_catalog_projects`, the
  site-owned half of a catalogue that several deployments will share
  (see [databases-and-distribution.md](/docs/modules_handbook/shared/project-catalogue/databases-and-distribution.md)
  §3; **not** the same thing as `market_sites` — see
  [neighbours-and-legacy.md](/docs/modules_handbook/shared/project-catalogue/neighbours-and-legacy.md) §4).
  - **Publication stays canonical.** `catalog_projects.published_at` answers
    "is this record good enough to show" — the completeness contract above — so
    it belongs to the record and reflects on every site. WHICH published
    records a site lists is a separate question, and that is the new table.
  - **A `site_catalog_projects` row always wins; with no row the site's
    `catalogue_mode` decides.** `Site::MODE_INHERIT` takes the catalogue as it
    comes (what petav3 does today, so this changed nothing on screen);
    `MODE_EXPLICIT` lists only what it has chosen, and is the default for a NEW
    site so standing up a white-label can never silently expose 19k Malaysian
    projects on a client's domain. `is_listed = false` is a stored suppression —
    on an inherit site, the absence of a row would just be inherited back.
  - **Public surfaces use `publiclyListed()`, engine internals keep
    `published()`.** The listing, detail page (including its related/nearby
    cards), sitemap and the markets count are site-scoped; nearby SUPPLY and
    rental comparables are not, because a comparable is evidence about a market
    and must not change per white-label.
  - **The detail RECORD itself is gated by
    `CatalogueDetailService::viewerMaySee($catalogue, $user)`** (2026-09-14),
    which is the single-row twin of the scope: `published_at` **and**
    `CatalogueFederationService::isListedOnSite($row)`, with `view-projects`
    previewing past both so the review workflow still works on a site that has
    not selected the project. It is asked on the connection that owns the row,
    and the listing decisions are plucked site-side and applied as a
    `whereIn` on uuid — never a cross-connection subquery. All three country
    pages route through it: MY in `CatalogueDetailService::forSlug`, HK in
    `BuildHkProjectDetail::visibleCatalogue` (so the HK lazy tabs inherit it),
    UAE in `BuildAeProjectDetail::execute()` — with `ProjectDetailController`'s
    `showUae` and the AE analysis endpoint asking again as defence in depth, so
    a third caller cannot skip it. ⚠️ **Until this landed the claim above was
    stale for the detail page**: it checked publication alone, so a project a
    site had suppressed (or never selected, on an explicit site) still rendered
    at its direct URL.
  - `Site::current()` resolves `config('site.key')` (env `SITE_KEY`) and caches
    for the process. **No matching row means no constraint** — a mistyped key
    degrades to the whole catalogue rather than blanking the public site.
  - ⚠️ **Nothing writes this table yet, and that now matters more than it
    used to.** `SiteCatalogProjectRepository` has no route and no controller —
    only tests. Now that the detail pages honour these rows (above), a
    deployment switched to `Site::MODE_EXPLICIT` would **404 every project
    page** until its selections exist. petav3's own site row is seeded
    `MODE_INHERIT` with no suppressions, so nothing here is reachable today;
    standing up a white-label means building the screen that calls these
    methods FIRST.
  - Writes go through `SiteCatalogProjectRepository` (`list` / `unlist` /
    `decide` / `listMany`); rows reference the master project by
    **`catalog_project_uuid`** (the §10 boundary rule — master ids may be
    re-keyed upstream, the uuid survives); `overrides` is the seam for approved per-site field
    values and nothing writes it yet. Per-site SLUGS are deliberately absent
    until a site needs a different URL — the column without the lookup wiring
    would be a trap.
- **List thumbnails come from `ResolvesCatalogueCover`** (2026-08-09), shared by
  both catalogue lists because getting a cover right is three separate decisions
  and each list got one of them wrong at some point: which kinds count
  (hero, else gallery, active only), that the relation must be EAGER-loaded, and
  that catalogue media is a UNION of a scraped `url` and a stored `media_id`
  on GCS — the stored side needs a signed URL, so reading `url` directly
  renders a blank box for exactly the records that have a real hero.
  ⚠️ **`media_id` does NOT mean "an admin uploaded this".** A SCRAPED row can be
  promoted to a stored representation (that is what the `store-*-media` commands
  do): it then carries `media_id` with `url` NULL **while keeping its
  `catalog_project_source_id` / `source_key`**. So provenance is the source
  columns, never the presence of `media_id`, and code that partitions on
  `media_id` to decide "uploaded vs scraped" is wrong for every promoted row. Both the New Project canonical tabs and the Subsale list simply never
  SELECTED any media and showed "No image" beside records that plainly had one.
  - The two lists render an ABSENT cover differently, on purpose. On the New
    Project tabs a hero is a publication requirement, so every row has one and a
    missing image is worth naming. In Subsale only **~7%** of records carry any
    image (the resale file is transaction data, not marketing), so an empty
    cover is a faint dashed outline that holds the row's alignment and says
    nothing — fifteen thousand boxes reading "No image" would be the loudest
    thing on the screen while telling the reader nothing actionable.
- **Spreadsheet export — one whole database per file** (2026-08-09).
  `App\Exports\CatalogueDatabaseExport` defines the columns, the row set and the
  escaping ONCE, and has two consumers: the **Export all (CSV)** button on both
  catalogue pages (`GET {subsale|new-projects}/export`, `?format=csv|xlsx`, via
  `ExportsResource`) and `php artisan catalogue:export-csv`, which writes both
  files to `storage/app/catalogue/`. A second hand-written query is how two
  files that claim to be the same data quietly stop being it.
  - Columns: **ID · Project · Completion Year · Location · State · Type ·
    Developer** — id FIRST so a row can be pasted straight back into
    `/manage/property/catalog/{id}`, developer LAST being the widest column and
    the most often empty. The id is the INTEGER, not the uuid: §7 keeps
    sequential ids out of PUBLIC urls, but the Manage catalogue routes are keyed
    by the integer, and an identifier nobody can look up is not an identifier.
    The console command sorts by the *Project* column located by NAME, not by a
    literal index — it was `$row[0]` until the id went in front of it, and the
    file silently came out ordered by id-as-string with nothing failing.
  - Rows are the canonicals with an ACTIVE source from that database's feeds, so
    a record its feed has stopped covering drops out. A development that both
    launches and trades on the secondary market carries both kinds of feed and
    appears in **both** files (~1,170 of them): each file answers "what is in
    this database", and it is in both.
  - It exports the WHOLE database, ignoring the page's filters and tab — which
    is why the control is labelled **Export all** and is NOT the shared
    `ExportMenu`. That component forwards the screen's query string, which would
    promise filtering this file does not do; §14's "the file must match the
    screen" rule is satisfied by not making the claim.
  - Ordered by name THEN id — a total order, because maatwebsite chunks a
    `FromQuery` and chunking a non-unique order can repeat or skip rows, which
    in an export is a silent wrong answer rather than a visible error.
  - Every text cell runs through `EscapesCsvFormulas`; the year does not
    (escaping a number turns the column into text). A UTF-8 BOM is written on
    the console command's files so Excel does not read them as Latin-1.
  - Expect the subsale file to be patchy where the resale feed simply has no
    data: completion year ~30% filled, developer ~39%. The New Project file is
    ~73% / ~100%.
- **Publication review — four people, four different names** (2026-08-09).
  Publishing used to be one button held by anyone with `manage-projects`. It is
  now a round: an **initiator** submits, **two colleagues read it cold**, and a
  named **approver** releases it. `published_at` alone could never answer "who
  checked this?", and that is the question the process exists for.
  - **Three tables.** `catalog_review_panels` (who may be ASKED, and in which
    capacity), `catalog_publish_reviews` (one submission of one project),
    `catalog_review_assignments` (one person's seat on that submission, and
    what they did with it). Seeded roster: Ke Xin, Shawn and Boon review; Zen
    and Dylan approve.
  - **Why a table and not a role or a permission.** Neither can express the
    pool. The users screen syncs a SINGLE main role, so a second one is wiped by
    the next edit of that user; and super-admin is granted every permission,
    which would put all six super-admins in the approver pool. A table also
    means changing who reviews is an admin action, not a deploy. It is seeded by
    MIGRATION rather than a seeder because deploys here run migrations and skip
    seeders — an empty panel is a publish button nobody can use.
  - **Eligibility is not permission.** Panel membership only makes someone
    offerable on the submit form. Whether they may act on a PARTICULAR round is
    their assignment row, looked up from the signed-in user and never taken from
    the payload. Route middleware can only say "an admin who manages projects".
  - **The initiator is CHOSEN, the actor is RECORDED.** The submit form asks
    "who are you?" because the person at the keyboard may not be on the account.
    The chosen name lands in `initiated_by`; the account that actually clicked
    lands in `created_by` via `RecordsBlame`. Both are kept, so the trail says
    who it is attributed to *and* who did it. The chosen initiator is still held
    to the reviewer panel, since it is the one seat a crafted payload could name
    freely.
  - **Every transition rule lives in the repository**, because they are all
    rules about transitions and a rule enforced in a controller is a rule the
    next controller forgets: a project cannot have two open rounds (serialised
    with `lockForUpdate` on the project row, so the check and the insert cannot
    be stepped between); one person cannot fill two seats; the approver cannot
    act before both cold-eye reviews are in; and a reviewer whose page was
    rendered before the round closed cannot write a sign-off into it.
  - **Completeness is checked at BOTH ends** — at submission, so reviewers are
    never handed a record that could not publish anyway, and again before the
    approval is written. Approving and publishing are two transactions, so
    checking after would leave a round marked approved on a record the contract
    then refuses to release.
  - **A sent-back round is CLOSED**, not reopened. The initiator fixes the
    record and submits a fresh round, so each round is one intact story rather
    than a mutable one. Requesting changes requires a note; approving does not.
  - **Reads go through `CataloguePublishReviewService`**, never straight to the
    models — the review page, the four New Project tabs and the Published tab
    all ask the same question, and its list path is BATCHED (one query for a
    page of 25, not three per row).
  - Files: `Src\Analysis\Review\*`,
    `Src\Analysis\Repositories\CatalogPublishReviewRepository` (+ facade),
    `App\Services\Property\CataloguePublishReviewService`,
    `App\Http\Controllers\Manage\Property\CatalogPublishReviewController`,
    `Components/Catalog/PublishReviewCell.vue`,
    `Catalog/Partials/PublishReviewModal.vue`,
    `Catalog/Partials/PublishReviewPanel.vue`.
- **Featured projects (the editorial shortlist).** The Published tab's ★ pins a
  live project to the TOP of BOTH new-project listings — the public
  `/new-projects` and the portal's Analyze Property → New Project tab — with a
  typed number for the running order among the pinned ones (1 first).
  - **A SITE table, never a catalogue column.** `catalog_project_highlights`
    lives in this deployment's own database and names the project by uuid. A
    `is_featured` column on `catalog_projects` would put one platform's picks
    on the top of every other platform reading the shared master.
  - **Pinning ORDERS, it does not inject.** The pin is an extra leading sort key
    on both sides of the federated listing (SQL `CASE … END` on `uuid`, plus the
    identical rank in the PHP merge key — miss the second and a pinned row from
    one catalogue is re-sorted back under the other's). A pinned project the
    visitor's search or region filter excludes stays excluded, or the filter
    stops meaning what it says.
  - **Only a published project may be pinned**, checked in the controller: the
    listings show published rows, so pinning an unpublished one would produce a
    top slot nobody outside the admin can see.
  - **Not gated by `catalogue.edit`.** That middleware protects the shared
    master from off-domain edits; a pin writes no catalogue row, so a platform
    may feature a master project it may not edit. `manage-projects` still
    applies.
  - The card carries a **Featured** badge on both surfaces — a curated top with
    no explanation reads as a broken sort.
  - Files: `src/Analysis/Highlight/CatalogProjectHighlight.php`,
    `Src\Analysis\Repositories\CatalogProjectHighlightRepository` (+ facade),
    `App\Http\Controllers\Manage\Property\CatalogHighlightController`,
    `App\Http\Requests\Manage\Property\StoreCatalogHighlightRequest`,
    `app/Actions/BuildNewProjectListing.php`,
    `database/migrations/2026_08_27_150000_create_catalog_project_highlights_table.php`.
- **Nothing auto-publishes.** No provider carries `publish_on_ingest` any more.
  PropertySifu did until 2026-08-06 — it was the one path by which a new project
  reached the public site with no human ever seeing it, no source review and no
  look at the market figures the page would quote. Records published under the
  old rule stay published.
- **Project Detail provider contract** — the page selects providers by fact:
  PropertySifu owns official units/launch facts, EdgeProp owns market
  comparables/analysis amenities, and `petav2_legacy` is compatibility evidence
  only. The provider-separated path is permanent; see
  [Project Detail](/docs/modules_handbook/main/project-detail/readMe.md).
- **Sync between deployments** — `catalogue:export` / `catalogue:import`
  (SCHEMA_VERSION 4, v1 read-compat), uuid-idempotent with a
  collation-exact package preflight; media travels with stable source
  identity or shared-bucket object references (G9), never signed URLs.
  **Matching is by UUID only** — there is no name/coordinate fallback, so a
  destination whose rows were built by its own `catalogue:sync` will not match
  and every aggregate lands as a CREATE. Dry-run first and read the
  create/update split before writing.
  - **The export refuses to ship rows whose lineage it cannot reproduce** —
    media with no storage record, and media/AI content whose provider source
    moved to another canonical or was deleted out from under it.
    `--skip-orphans` omits them instead of aborting; either way every omitted
    row is printed, grouped by reason and project. The guard is against
    clearing lineage *silently*, not against ever shipping without it.
  - **`--resume` commits each aggregate in its own transaction** and keeps going
    past failures, instead of one all-or-nothing transaction. The default is
    right for shipping a package as one consistent snapshot; `--resume` is right
    for RECONCILING two deployments that have drifted, where a handful of
    genuinely ambiguous records should not cost the other seventeen thousand
    their import. Safe to repeat: every write in `importAggregate` is an upsert
    keyed on stable identity, so re-running writes the same values again rather
    than duplicating anything. Failures are grouped BY CAUSE at the end, because
    a reconciliation failure is rarely one record's problem. `--max-errors=N`
    stops early.
    - **Run it until every part reports `0 failed` — usually twice.** Developer
      identity resolves against the `developers` table as it currently stands,
      and the import BUILDS that table as it goes. An aggregate early in pass
      one can refuse against a half-built table and then resolve cleanly on
      pass two, once a later aggregate has created the developer it needed
      (2026-08-11: ~922 aggregates failed their developer step on pass one, all
      succeeded on pass two, nothing changed in between). Only failures that
      SURVIVE a second pass are genuine ambiguity.
  - **A CHILD row's identity is its natural key, not its uuid.** A canonical
    project is matched by uuid, but `catalog_media`, `catalog_floor_plans`
    and `developers` each carry a second unique key derived
    from the row itself — `(catalog_project_source_id, source_key)`,
    `(project, identity_key)` and `slug`.
    ⚠️ **`catalog_ai_contents` is the exception and behaves the opposite way**:
    the importer looks it up by **uuid only**, and on a miss CREATES the row with
    the package's uuid at `max(version) + 1`, side-stepping its natural key
    `(project, kind, locale, version)` entirely. Re-importing a package whose AI
    prose was regenerated upstream therefore APPENDS a version rather than
    matching the existing one.
    Two deployments that ingested the
    same provider asset hold the SAME natural key under DIFFERENT uuids, because
    each minted its own on create. Matching a child by uuid alone therefore takes
    the create branch and the unique index rejects the insert (`1062 Duplicate
    entry … catalog_media_source_identity_unique`) — a failure the uuid-only
    preflight cannot predict, so a package can report `0 conflicts` and still
    abort on write. Import and `verify-sync` both fall back to the natural key,
    and a row matched that way KEEPS the destination's uuid: it is a public
    identifier (`HasUuid`) other rows there already reference, and rewriting it
    buys nothing the natural key has not already established. `catalogue:import`
    reports how many rows it matched this way; `verify-sync` counts the uuid
    difference as contract rather than drift (`--strict` to see them).
    `Developer::importPortableIdentity` adopts by `slug` for the same reason, but
    still REFUSES when slug and name/alias resolve to two different local rows —
    that is a real ambiguity, not a uuid difference, and no import should guess
    which developer was meant.
  - **The importer does not copy everything, deliberately.** A field the
    DESTINATION has pinned in `manual_overrides` keeps its local value
    (`array_diff_key($canonical, $localOverrides)`), the local `slug` is
    workflow-owned on update, and `manual_overrides` is merged with local
    winning. `developer` / `developer_brand` are not in `CANONICAL_FIELDS` and
    never travel at all — the developers PIVOT does, and the text columns are
    only a fallback behind `primaryDeveloperName()`.
  - **`catalogue:verify-sync {package}` proves the result** — read-only, run it
    on either side. It walks the package and asks the local database whether it
    agrees field by field, classifying each difference as contract (a
    destination pin, the workflow-owned slug) or real drift, and exits non-zero
    on drift so a deploy script can gate on it. Run it on the SOURCE first: a
    package exported from a database must verify clean against that same
    database, which is what proves the comparator itself is honest.

### Who may edit the master (the domain gate)

An edit to a master row lands in `master_projects` and is seen by every platform at
once, so writes to master rows are allowed only from the admin domains named in
`config('site.catalogue_edit_domains')` (env `CATALOGUE_EDIT_DOMAINS`, default
`propertylabglobal.com`; a bare domain admits its subdomains; `*` disables the gate —
local dev and the test suite). Everywhere else the master catalogue is **read-only**:
the edit affordances hide (per-row `can_edit` on the index, `canEditCatalogue` on
Show / Review / New Project Database — the pages fold it into their `canManage`
computed) and the `{id}`-keyed write routes refuse with 403 via the `catalogue.edit`
middleware (`App\Http\Middleware\EnsureCatalogueEditable`).

Three deliberate edges:

- **Per row, not per page.** A platform-created project lives in the site database
  and stays editable from any domain — nobody else can see it, so there is nobody
  else to protect. The check is `CatalogueFederationService::canEdit($row, $host)`.
- **Creation is ungated.** `POST /manage/property/catalog` never touches the master
  (creation is platform-only by design), so it carries no `catalogue.edit`.
- **Developers carry the same gate.** They live in the master too, so
  `PUT /manage/property/developers/{id}` runs `catalogue.edit:developer` (the
  middleware's second resolver), the directory sends per-row `can_edit`, and the
  directory/search/update paths are federated — a platform-created developer lists,
  is findable by the project form's picker, and stays editable from any domain.
- **The whole edit → review → publish flow is preserved** on an allowed domain; the
  gate decides *where* the flow may run, never *how*.

⚠️ **The gate is decided by the DOMAIN, not by which database the connection points at — it
is NOT inert in single-database mode.** `CATALOGUE_USE_DEFAULT_CONNECTION` only swaps which
credentials the `catalogue` connection uses; `CatalogProject` still pins
`protected $connection = self::CONNECTION` unconditionally, so a master row always hydrates
under the connection name `catalogue` and `canEdit()`'s
`getConnectionName() !== CatalogProject::CONNECTION` short-circuit never fires. The suite is
ungated only because `phpunit.xml` forces `CATALOGUE_EDIT_DOMAINS=*`.

**A local box must set `CATALOGUE_EDIT_DOMAINS=*` in its own `.env`.** Both `config/site.php`
and `.env.example` default to `propertylabglobal.com`, and under that default every catalogue
write from `petav3.test` answers **403** and every edit affordance hides — single-database mode
or not. That is the single most common "why can't I edit anything" on a fresh checkout.

The one real exemption is **per ROW, not per mode**: a project this platform created itself is
written on `CatalogProject::creationConnection()` (the default connection), so it fails the
connection-name test and stays editable from any domain. The gate's own tests pin rows to the
catalogue connection to exercise the master path (`CatalogueEditDomainTest`).

## Reference usage

Ingest a provider record (adapters do this; never write catalogue tables
directly):

    app(CatalogueIngestionService::class)->run(app(EdgepropFileAdapter::class), ['file' => $path, 'full' => true]);

Read a detail payload:

    app(CatalogueDetailService::class)->forSlug($country, $slug, $user, $presetKey);

Refresh AI content after a catalogue mutation (already wired into ingestion
and the admin override endpoints):

    app(CatalogueContentService::class)->refresh($catalogue);

## Related files

- `app/Services/Property/{ProjectCatalogueMergeService,CatalogueClassificationNormalizer,CatalogueCompletenessService,CatalogueContentService,CatalogueDetailService,CatalogueMergeCandidateService,CatalogueSourceComparisonService}.php`
- `app/Services/Property/Ingestion/` (CatalogueIngestionService, the
  ProviderAdapter contract, NormalizedProjectRecord, adapters)
- `app/Services/Property/Scraping/` (ScraperApiClient, NextData, CrawlState,
  the two crawlers + normalizers, ProjectNameKey)
- `app/Console/Commands/{SyncCatalogue,ImportEdgepropData,MapHkSources,MapMySources,ScrapeEdgepropNewLaunches,ScrapeIpropertyNewLaunches,ReportCatalogueCompleteness,ExportProjectCatalogue,ImportProjectCatalogue,NormaliseCatalogueClassification}.php`
- `app/Http/Controllers/Manage/Property/{CatalogController,CatalogScrapedController,CatalogReviewController,DeveloperController}.php`,
  `app/Http/Requests/Manage/Property/{Store,Update}CatalogProjectRequest.php`,
  `{Store,Update}DeveloperRequest.php`, `DeveloperQueryRequest.php`,
  `UpdateCatalogOverridesRequest.php`, `UpdateCatalogParentRequest.php`,
  `ScrapedSourceQueryRequest.php`, `ApplyCatalogueReviewRequest.php`
- `src/Analysis/Repositories/{CatalogProject,CatalogFloorPlan,CatalogMedia,CatalogProjectHighlight,Developer,SiteCatalogProject}Repository.php`
  (+ their facades), `src/Analysis/Exceptions/CatalogProjectInUseException.php`
- Site listing layer: `src/Common/Site.php`,
  `src/Analysis/Reference/SiteCatalogProject.php`, `config/site.php`,
  `database/migrations/2026_08_18_100001_create_sites_table.php` +
  `..._100002_create_site_catalog_projects_table.php`,
  `tests/Feature/Property/SiteCatalogueListingTest.php`
- `resources/js/Pages/Manage/Property/Catalog/` — `Index.vue`, `Show.vue`
  (`ShowTabs`, **six** tabs: Overview / Floor plans / Media / Sources / VR360 /
  Live preview. Overview, Floor plans, Sources, VR and Preview are
  `Partials/Tabs/*Tab.vue`; **Media is `Partials/CatalogMediaSection.vue`**, not
  a `*Tab.vue`. Live preview iframes the REAL public page with `embed=1`),
  `Partials/` (the shared project + floor-plan form modals),
  `Review.vue` (combine + source comparison + live preview — the one decision
  screen), `Scraped/` (the scraped review page and its detail partial)
- `resources/js/Pages/Manage/Property/Developers/` — the DataTable directory
  and shared create/edit modal; `Catalog/Partials/CatalogDevelopersInput.vue`
  is its dependent consumer.
- `resources/js/Components/{ChipsInput,PairsInput,StructuredRowsInput,ImageDropzone,JsonInput}.vue`
- `src/Analysis/Reference/` (models), `config/project_catalogue.php`
- `docs/property-detail-contract.md`, `docs/hk-catalogue-mapping.md`,
  `docs/petahk-project-catalogue-handoff.md`,
  `docs/modules_handbook/main/project-detail/readMe.md`
