# Shared Area Guide registry

**Nav:** Manage -> Portal -> Area Guide (`/manage/area-guide`).
**Reader:** `/property/academy?tab=area-guide`; its Learning Hub tab button remains hidden.
**Status (2026-09-18):** **LIVE on production.** The registry runs on the shared master
(`master_projects`), the guide is open to members, and Bangsar South carries real content —
4 published building models, 7 panoramas, 39 panorama links and 3 custom buildings. See
*Working on the Area Guide locally* for the two local modes. The wider map slice — published project markers and the detail
drawer, uploaded building models, panoramas and location videos — is implemented as well and
documented in the
[map handbook](/docs/modules_handbook/shared/project-catalogue/area-guide-map/readMe.md).
Remaining unverified items stay in the
[requirements checklist](/docs/modules_handbook/main/area-guide/catalogue-map-requirements.md).

## What it does

Stores editable countries, regions and areas in one registry used by the Guide and its
existing learning consumers. An administrator can add or edit records from the current
site's Manage portal, including names, English/Chinese stories and facts, coordinates,
visibility, ordering, map settings and supported walking settings.

Production sites are intended to read/write the same live master database. This does not
require another application or a central-login redirect. Local development can instead
use an explicitly isolated content database while Catalogue projects keep their existing
read-only master connection. The editor labels that mode as a local preview.

This slice does **not** move Area Tutorials, member progress, walk visits, narration audio
ownership, users, leads or bookings. Shared file/media ownership **is** implemented, for map
uploads only: `area_guide_media` / `area_guide_media_cleanups` and `Src\Common\AreaGuideMedia`
live on the same content connection (see the
[map handbook](/docs/modules_handbook/shared/project-catalogue/area-guide-map/readMe.md#shared-media-and-deployment-runbook)).
Narration audio stays site-local, so moving story text still does not move its audio files.

## How it works

### Ownership and rollout switches

| Setting | Default | Meaning |
|---|---|---|
| `AREA_GUIDE_CONTENT_SOURCE` | `legacy` | `legacy` reads bundled sources; `database` reads the registry tables. |
| `AREA_GUIDE_CONTENT_CONNECTION` | `catalogue` | Server-controlled connection: `catalogue` or the isolated local development connection `area_guide_local`. |
| `AREA_GUIDE_CONTENT_EDITING` | disabled | Enables edits only alongside database mode, administrator role and the Manage permission. |
| `AREA_GUIDE_LOCAL_DATABASE` | `petav3_area_guide` | Local override's schema; must be different from the site's database. |
| `AREA_GUIDE_SITE_KEY` | application URL hostname | Stable source-site identity in the shared actor snapshot. |

`AreaGuideContentConnection` restricts the local override to `local`/`testing`, a loopback
host and a separate database. Outside those environments the `catalogue` host, port and
database must match `catalogue_master`; credentials may differ. This checks ownership
without granting writes or redirecting the application's default connection. A production
Catalogue copy is not silently accepted as a private Area Guide registry.

Once `database` mode is enabled, an empty/unavailable database never falls back to the old
bundled stories. The Learning Hub remains available, with its Guide data withheld when the
registry is unavailable; Manage renders an unavailable state. Rollout therefore imports
before switching readers. Existing deployments remain on `legacy` until explicitly enabled.

The **legacy** source now fails the same way instead of erroring. `LegacyAreaGuideContent`
throws `Src\AreaGuide\Support\AreaGuideContentUnavailable` — a `LogicException` — for a missing
or unreadable file, invalid JSON, an unsupported version, an entry with no `key`, and a country
key its `COUNTRY_CODES` map does not know; a non-array child list or locale override reads as
empty rather than raising a `TypeError`. Every consumer's existing `QueryException|LogicException`
catch therefore degrades the Guide to "unavailable" rather than 500-ing the Learning Hub.
Adding a country to `resources/data/area-guide/legacy-countries.json` still requires adding its
ISO code to that map.

The current-site editor requires `isAdmin()` plus `view-area-guide`. Writes also require
`manage-area-guide` and the editing switch. The generic Manage middleware alone is not
enough: it also admits some sales roles. Permissions are installed with an additive site
migration that grants the existing administrator roles; it does not reset role assignments.
Read-only sites keep editing disabled. The switch does not override database grants.

### Schema and writes

The catalogue migration creates `area_guide_countries`, `area_guide_regions` and
`area_guide_areas`. Each entity has integer `id`, public `uuid`, stable `key`, revision,
sort order, visibility, translations, profile, import hash, blame columns and soft delete.
Relationships use indexed integer keys without database foreign-key constraints.

Country records carry ISO 3166-1 alpha-2 country codes, label and flag. Region records carry
their parent, camera coordinates, label and Soon state. Area records carry their parent,
name, coordinates and walking parameters. Profile data preserves existing map asset names,
presentation metadata and legacy copy; it is not an arbitrary settings dump. `SaveAreaGuideRequest`
whitelists every nested key, value and coordinate range, including the two structures that used
to be unchecked bags:

- **`profile.states`** (the zoomed-out state layer, keyed by the polygon name in the bundled
  map geometry) is validated entry by entry. The KEY must be a plain polygon name — letters or
  digits, then letters, digits, spaces, apostrophes, parentheses and hyphens; a dot is refused
  because it would split into nested validation paths. Each entry is
  `array:en,zh,color,label_at,lift,on_dark,enclave`, with a `#RRGGBB` colour, `label_at` as a
  2-element lng/lat pair inside ±180 / ±85, `lift` 0–10 and boolean `on_dark` / `enclave`.
- ⚠️ **`profile.video` and `profile.video_media` are RETIRED** (2026-09-19, user: "please
  drop/delete all on the ground video of the area"). An area no longer has a video of its own;
  a video hangs on the map MARKER for the place it was shot at. They are **not in the `array:`
  whitelist any more**, and that is exactly why they need a bullet rather than a deletion:
  - Laravel's `array:` rule fails the **whole attribute** on one unlisted key, so a stored
    `video` riding back in on a save 422'd **every area that had ever had one** — they became
    unsavable from their own editor. The fix has three halves reading ONE list,
    `AreaGuideContent::RETIRED_PROFILE_KEYS`: `SaveAreaGuideRequest::prepareForValidation()`
    drops them from an incoming payload (so an area heals on its next save, before anything
    else is run), `LegacyAreaGuideContent` excludes them on import, and
    **`php artisan area-guide:strip-area-copy --apply`** clears them out of stored rows.
    **Run that command on production.**
  - **Do NOT re-add either key to the whitelist.** The feature is gone by the user's decision;
    the fix is that nothing writes them any more.
  - `Src\AreaGuide\Support\VideoLink` **survives** and is still the one normaliser for marker
    videos: a **bare eleven-character value is YouTube**, `vimeo:<id>` is Vimeo, and
    `vimeo:<id>:<hash>` is an **unlisted** Vimeo video whose embed 404s without that hash. One
    deliberate ambiguity kept: an all-digit ELEVEN-character *paste* is read as Vimeo, because
    that is what a Vimeo id looks like while a YouTube id is random base64url — and nothing
    already stored moves, since `VideoLink::provider()` reads a bare value as YouTube whatever
    its digits.
- **`profile.boundary` is the TRACED RING that says what the area is** (2026-09-18).
  `array|null`, 3–200 points, each `[lng, lat]`. Stored **OPEN** — the first point is not
  repeated — and every renderer closes it, so a ring that arrives closed and one that does not
  draw identically. A 1–2 point ring is a 422 naming the field rather than a trace that silently
  vanishes. The admin traces it on a real map in the registry editor
  ([AreaMapField.vue](/resources/js/Pages/Manage/AreaGuide/Partials/AreaMapField.vue)): click to
  drop points, click the first to close, drag any vertex to adjust.
- **`profile.chapters` is one CAMERA per story chapter** (2026-09-18). `array|null`, max 12,
  **index-aligned with `translations[locale].story`** — chapter *n* is `story[n]` in the reader's
  language plus `chapters[n]` for where the map should be. Each entry is
  `array:center,zoom,pitch,bearing`, every key optional. **A chapter with no `center` does not
  move the map**, which is what made this backward compatible with every existing two-paragraph
  story. The cameras are deliberately NOT per-locale: a place is a place in both languages, and a
  second per-locale structure is a second thing to fall out of step.
  ⚠️ `profile` is validated with Laravel's `array:` rule, which counts a key that is
  present-and-null, so the editor OMITS both keys when they are empty rather than sending null —
  an untraced area posts exactly what it posted before this feature existed.
- **`profile.chapters.*.images` is the chapter's PICTURES, as avatar uuids** (2026-09-19).
  `array|null`, max 6, each a 36-character uuid of an `area_guide_avatars` row — the shared
  library, not a per-chapter upload, so one place an admin puts an image serves many places.
  A `validator->after()` hook checks every uuid **exists and belongs to this area's country**
  and names the exact slot (`profile.chapters.2.images.1`) when it does not: the reader's
  presentation silently drops an unresolvable picture, so without this the only symptom would
  be a chapter that quietly lost an image nobody could explain.
  ⚠️ **The reader never receives these uuids.** `AreaGuideRegistry::resolveChapterImages()`
  swaps them for `image_urls` addressed by area + chapter + index, in ONE query for the whole
  tree, and drops the uuids. The library's own image route is admin-gated, so a reader holding
  uuids could otherwise walk the entire library.

**The avatar library (`area_guide_avatars`, 2026-09-19).** The user asked for the uploaded
avatar to be "reusable for this area guide", and that turned a per-marker upload into a shared
library: an admin uploads an image once and picks it wherever a face is needed — a video
marker's avatar on the map, and a story chapter's pictures. Rows are scoped by `country_code`
(so an editor only ever sees the library for the country they are working in), carry the image
as a `media` row through `MediaService`, and are referenced by `uuid` from
`area_guide_assets.avatar_id` and from `profile.chapters.*.images`. Two catalogue migrations
create it: `2026_09_19_100000_create_area_guide_avatars` and
`2026_09_19_100100_add_avatar_id_to_area_guide_assets`. The media uuid becomes each served
URL's `?v=`, so replacing a picture never serves the old one from a browser cache.
- **`tagline` and `facts` are REMOVED from `TRANSLATION_FIELDS`** (2026-09-18, user), which is now
  `['label', 'name', 'blurb', 'story']` — `label` and `blurb` stay because countries and regions
  use them. ⚠️ `translations.*` uses the same `array:` rule, so a stored `tagline` or `facts`
  riding back in a save would 422 **every existing area**: the editor whitelists the four
  remaining fields for every stored locale when it hydrates, and `area-guide:strip-area-copy`
  clears the old values out of the rows so nothing is left to leak back.
- **`translations`** keys must be genuine locale codes — `en`, `zh`, `zh-TW`
  (`AreaGuideContent::LOCALE_PATTERN`, plus a reserved-name list), and each locale carries only
  the six copy fields in `AreaGuideContent::TRANSLATION_FIELDS`. A "locale" called `map`,
  `story` or `badge` is now a 422. This matters because reader presentation merges a locale
  object beside the profile fields; see *One reader registry* below for the second guard.

A **country's map camera is optional in the editor.** Leaving centre and zoom blank stores no
`center` / `zoom` / `minZoom` at all (the modal drops blank camera keys before posting), so the
reader keeps deriving the view from the country's own region and area pins
(`registry.js countryMapProfile`). This is why Hong Kong and the United Arab Emirates can be
re-saved unchanged without acquiring a `0,0` camera. A half-typed centre is still sent as typed,
so validation can name the missing half.

`AreaGuideRegistryRepository` is the only writer. It accepts nested model-keyed arrays and
uses transactions on the resolved content connection. Updates take a row lock and compare
the submitted revision before changing data. A stale edit returns a validation error; the
administrator must reload the newer record. Keys, country codes and parents cannot be
changed through an update. Archiving a parent with live children is rejected. Soft-deleted
keys remain reserved so historical links cannot silently refer to a new record.

The compatible integer blame fields are accompanied by `actor_context` snapshots for
created/updated/deleted actions: site key, administrator UUID and display name. The UI
shows the last editor and source site rather than resolving a foreign site's integer user
ID through the current site's users table — today the Manage list renders that "Edited by
name · site" line on **area** rows (countries and regions carry the snapshot but do not
display it). This is an actor snapshot, not a full version history browser.

The editor page receives a **presented** tree, not raw rows: `uuid`, `key`, `revision`,
`sort_order`, `is_active`, `translations`, `profile`, the record's own typed fields, its
parent's UUID (`country_uuid` / `region_uuid`, which is what the modal binds) and an
`actor_context` reduced to `{name, site_key}` per event — the two fields the "Edited by" line
renders. Integer ids, `country_id` / `region_id`, `created_by` / `updated_by` / `deleted_by`,
`import_hash` and other sites' administrator UUIDs never reach the browser.

Visibility in the registry itself is an active flag. The map assets' draft/publication and
audience lifecycle **is** implemented — see the
[map handbook](/docs/modules_handbook/shared/project-catalogue/area-guide-map/readMe.md#upload-preview-replacement-and-deletion):
new assets start as drafts, replacing the main file forces a draft again, and audience is
server-forced (`admin` for panoramas, `guide` otherwise). The registry editor still uses
visibility controls only; its guarded archive endpoint exists but has no destructive button
in the UI, and there is no restore path for an archived record.

### One reader registry

`AreaGuideRegistry::countries()` returns the active hierarchy and full localized content.
`countries(false)` returns lightweight identity/map choices without stories or actor data.
Presentation filters translations before merging them: only genuine locale keys, and inside
each locale only the six copy fields (`label`, `name`, `blurb`, `tagline`, `story`, `facts`).
A translation stored under a profile field's name therefore cannot overwrite that field for
readers — defence in depth for rows written before the validation rule above existed, and it
matters twice because `registry.js localize()` spreads a locale object over the record.

`findArea()` and `walkableAreas()` resolve stable keys and omit Soon regions where a reader
cannot enter them. `walkableAreas()` also enforces `AreaGuideContentAccess::WALKABLE_COUNTRIES`
by country ISO code itself — the rule no longer lives only in the Form Request, so an area
flagged walkable by an import or a direct database write outside Malaysia is never offered to
the station lesson. A **null** `walk_radius_m` / `walk_stations` falls back to
`AreaGuideArea::DEFAULT_WALK_RADIUS_M` (1200 m) and `::DEFAULT_WALK_STATIONS` (6) rather than 0,
which found no stations at all. (The editor form still requires both fields; the defaults cover
imported or directly written rows. A row written with an explicit `0` is still an empty station
list.)

Reads are memoised for the duration of **one HTTP request** — a `WeakMap` keyed by the current
`Request` object, and by source plus the configuration that read depends on, so the entry dies
with the request and a changed source or connection is re-read. Console commands and queue
workers never memoise. Every repository write calls `AreaGuideRegistry::forgetRequestMemo()`
after its transaction, so a read later in the same request sees the change. There is still no
cross-request cache: two sites pointing at the same content database read the same saved
revision on reload, and there is nothing to clear. Already-open browser push updates are not
provided.

Consumers use this registry as follows:

- CoursesController supplies full Guide content after existing access checks. Inertia's
  shared middleware supplies lightweight options only on relevant authenticated routes.
- `registry.js` hydrates map assets and localized text; an empty translation falls back to
  English. Country camera/bounds and region selection drive the continuous map. Existing
  illustrated Hong Kong and United Arab Emirates presentations remain available; new
  countries can use the street-map renderer without new country-specific components.
- Tutorial pickers, tutorial display links and Road handoffs use registry identities.
  Tutorial records and route lessons stay in the site database.
- Chat and narration dossier lookup use current Malaysia registry stories. Narration
  availability still refers to existing site-local media. Import/edit does not regenerate
  audio or invoke paid services.
- AreaStationFinder reads area centre, radius and station limit from the registry while
  retaining its existing published-project station-selection algorithm.

Country-independent **map/story data** is implemented. The legacy chat prompt, Road
teaching and walkable lesson are still Malaysia-specific. Chat has an explicit Malaysia
area allowlist, and the form rejects enabling walking outside Malaysia. Importing Hong Kong
or United Arab Emirates is not evidence that those older learning features support their
currencies, scoring or prompts.

### Import and local setup

`area-guide:import-registry` defaults to a dry run. The importer reads the versioned
`resources/data/area-guide/legacy-countries.json` snapshot and existing Malaysia PHP
story/game sources, then creates missing records by stable key only when `--apply` is used.
It never overwrites administrator edits or resurrects archived records. The whole tree is
imported in a transaction; no narration generation or media upload is triggered.

**The dry run is no longer database-free.** Both modes first print a read-only preview against
the target connection — `Preview: N to create; N already present; N held back under archived
parents; N conflicts.` — with no locks, no writes and no transaction. Each conflict names the
key and both parents, for example *"Region key 'dubai' already exists under country 'new-land',
but the import source places it under country 'uae'. Region keys are unique across every country
and cannot move."*, plus the two country-code cases. The command exits non-zero when any conflict
is listed, `--apply` refuses to write while one is, and a clash that appears between the preview
and the import rolls the whole tree back and is reported the same way. Operationally this means
the registry tables must already exist on the target connection: run the import **after** the
catalogue migration in the cutover order, not before it (a missing table surfaces as a raw
database error, not a friendly message).

### Working on the Area Guide locally (2026-09-18)

**There are two local modes, and the difference is whether you can EDIT.**

| `AREA_GUIDE_CONTENT_CONNECTION` | Reads | Writes |
|---|---|---|
| `catalogue` | production's shared `master_projects` | **none** — the local account is SELECT-only, so an edit fails closed rather than reaching production |
| `area_guide_local` | the isolated local `petav3_area_guide` | yours, and they never leave this machine |

Pointing at `catalogue` is how you look at exactly what readers see; it is NOT a way to edit
production, and `AREA_GUIDE_CONTENT_EDITING=false` takes the editor's forms away so nobody
tries. The editable mode is `area_guide_local`, and `AreaGuideContentConnection::name()`
guards it three ways: **local/testing environments only**, a **loopback host**, and a database
that is **not the site's own** — so the workspace can never quietly become production, nor
pollute `petav3`.

```dotenv
AREA_GUIDE_CONTENT_SOURCE=database
AREA_GUIDE_CONTENT_CONNECTION=area_guide_local
AREA_GUIDE_CONTENT_EDITING=true
AREA_GUIDE_LOCAL_DATABASE=petav3_area_guide
```

Then `php artisan config:cache` — **not** `config:clear`. A missing
`bootstrap/cache/config.php` makes local Apache 500 at random under parallel requests
(mod_php threads racing Dotenv's `putenv`), so this checkout keeps the config cached and
rebuilds it after every `.env` edit.

**Schema.** Five catalogue migrations build the whole registry, not one — the original
registry plus media, map assets, buildings and panorama links:

```shell
php artisan migrate --database=area_guide_local --path=database/migrations/catalogue/2026_09_13_160000_create_area_guide_registry.php
php artisan migrate --database=area_guide_local --path=database/migrations/catalogue/2026_09_13_220000_create_area_guide_media.php
php artisan migrate --database=area_guide_local --path=database/migrations/catalogue/2026_09_13_230000_create_area_guide_map_assets.php
php artisan migrate --database=area_guide_local --path=database/migrations/catalogue/2026_09_14_120000_create_area_guide_buildings.php
php artisan migrate --database=area_guide_local --path=database/migrations/catalogue/2026_09_14_140000_create_area_guide_panorama_links.php
php artisan migrate --database=mysql --path=database/migrations/2026_09_13_200001_grant_area_guide_permissions.php
```

File by file, never the whole `database/migrations/catalogue/` directory: it also carries
`catalog_*` alterations that belong to the master and that an unguarded re-run will fail on.

**Content — two ways to fill it.**

1. **From the versioned snapshot**, for a genuinely empty workspace:
   `php artisan area-guide:import-registry` (dry run), then `--apply`. It creates 3 countries,
   6 regions and 24 areas by stable key; re-running creates 0 and preserves all 33. It brings
   **copy only** — no assets, no panoramas, no markers, no buildings.
2. **From production**, which is what you want once real content exists there. Copy every
   `area_guide_*` table from the shared master into `petav3_area_guide`. The production account
   is SELECT-only, so this can only run in that direction. Copy **all ten** tables together —
   `countries`, `regions`, `areas`, `media`, `assets`, `placements`, `hotspots`,
   `panorama_links`, `buildings`, `building_tabs` — because `area_guide_media` is what turns a
   panorama row into a file, and an asset without its media row renders an empty viewer with no
   error. There are no schema-level foreign keys (GUIDELINES §7), so order does not matter.

⚠️ **The rows are only half of it: the FILES live in GCS.** An `area_guide_media` row names a
`disk`, and that disk name has to resolve to a bucket that actually holds the object. The
buckets are `petav3-prod` (production) and `petav3-dev` (development); `AREA_GUIDE_GCS_*` are
normally empty, so `area_guide_shared` inherits the general `GOOGLE_CLOUD_STORAGE_BUCKET`.
Move the objects with a **server-side** `StorageClient` copy (`$object->copy($target)`), which
duplicates inside Google and never crosses this machine's connection. The whole Area Guide
prefix is small — `area-guide/` was **11 objects, 92 MB** on 2026-09-18, against 34 GB for the
bucket as a whole (`catalogue/` alone is 26 GB), so copy the prefix, never the bucket.

⚠️ **Signing is OFFLINE, so a wrong bucket fails SILENTLY on the server.** A signed URL is
computed from the key and the object path without calling GCS at all: the app mints a
perfectly good URL, hands it to the browser, and the browser gets a 404. Nothing appears in
any log. When models, panoramas or area videos are blank locally while the rest of the page
is fine, check the bucket FIRST — every other part of that page comes from the database or
from YouTube. GCS answers "the specified bucket does not exist" for both a wrong NAME and a
missing PERMISSION, so the two cannot be told apart from the client; compare the bucket name
and the service-account file against production together.

⚠️ **Prefer the narrowest key for local.** A service account scoped to `petav3-dev` cannot
touch production even if a bucket name is mistyped; one that can write both buckets leaves the
name as the only thing standing between a local upload and production's storage.

On this checkout the development database is `petav3_area_guide`, the site remains `petav3`,
and the disposable feature-test database is `petav3_area_guide_testing`. The master credentials
and the project Catalogue connection are not touched by any of this, and local edits never
propagate to production sites.

### Showing readers ONE area (2026-09-18)

Every country, region and area carries `is_active`, and `AreaGuideRegistry::countries()`
filters on it at all three levels — so hiding a row simply removes it from the guide,
without deleting a story or touching a translation. The Manage → Area Guide editor has it
as the **Visibility** column, one modal save per row.

`php artisan area-guide:show-only {area}` does the same in one pass, because limiting the
guide to a single area is fifteen modal saves against a live shared database:

```shell
php artisan area-guide:show-only bangsar-south           # dry run: every row it would flip
php artisan area-guide:show-only bangsar-south --apply   # hide everything else
php artisan area-guide:show-only --all --apply           # every country, region and area visible again
```

It leaves the chosen area's region and country visible so the area stays reachable, writes
through `AreaGuideRegistryRepository::update()` (revision + actor context, like the editor),
and names each row before it moves. `--all` turns EVERYTHING on, so it is an exact undo only
while nothing else was deliberately hidden. **It writes to whatever the Area Guide content
connection points at** — run it on the box whose registry you mean to change.

### Production cutover — DONE (2026-09-18)

*(Kept as the runbook: the same steps apply to any further site joining the shared master.)*

An authorized deployment operator needs schema migration access to the real master and
each writing site's application credential needs the required table write grants. A
SELECT-only credential can remain a reader — that is exactly what a developer's machine
uses when it points at `catalogue` to look at live content. Do not put migration credentials
in the editor or broaden unrelated Catalogue edit gates.

Apply the **specific** registry migrations on the intended master with a deployment writer —
**one `--path` per file**, never the directory: it also carries `catalog_*` alterations,
and at least one of those has no `hasColumn` guard, so an unguarded re-run fails. Then
perform the dry run and create-only import, install site permissions on each site, and then
enable `database` source with the verified `catalogue` connection. Do not run all catalogue
migrations blindly or use `migrate:fresh` on the master. The implemented shared media/outbox
tables, map asset migrations, private storage configuration and scoped runtime grants are
documented in the [Area Guide map runbook](/docs/modules_handbook/shared/project-catalogue/area-guide-map/readMe.md#shared-media-and-deployment-runbook).
Production deployment of that wider map feature remains a separate verified cutover.

Reverting the source switch to `legacy` preserves all database records but hides newly
authored database content from readers; it is not a data rollback. No drop-table rollback
or production migration was run in this implementation slice.

### Verification and limits

- Registry/repository tests exercise import idempotence, translations, independent site
  reads, connection ownership, stale revisions, archival and actor identity.
- Feature tests run against a newly created disposable schema, including fresh migration
  replay, Manage authorization/validation, existing Guide endpoints and Area Tutorials.
- Frontend tests exercise authoritative empty data, deep links, country camera options,
  tutorial/Road selection, the hidden tab and real Inertia modal form hydration/submission.
- A local HTTP-kernel probe returned 200, the Manage component, editing enabled and all
  3/6/24 records under the real local configuration. This is not a browser rendering test.
- Final counts/build status and unfinished requirements are recorded in the
  [requirements checklist](/docs/modules_handbook/main/area-guide/catalogue-map-requirements.md).
  Browser graphics/mobile checks and a real two-deployment master cutover remain unverified.

**Remediation checkpoint — 2026-09-14.** The hardening described above (translation locale
whitelist and reader filter, `profile.states` validation, optional country camera, import
preview and named conflicts, walkable-country enforcement and walk defaults, the presented
editor tree, the legacy-source exception, the per-request memo) was implemented and re-verified
on this checkout with automated tests only:

| Selection | Result |
|---|---|
| `tests/Unit/AreaGuide` (registry, connection config, station finder, spherical polygon — since removed with the traced outlines, angle rule, station question) | 92 tests / 492 assertions passed |
| `tests/Feature/Manage/AreaGuideContentTest.php` | 12 tests / 135 assertions passed |
| `tests/Feature/Main/Portal/AreaGuide` (guide, chat, content, endpoint gate, game, lockdown, narration) | 62 tests / 875 assertions passed |
| `php scripts/check-migration-constants.php` | OK — 182 constant references across 692 migrations |
| `php scripts/check-suite-links.php` | OK — bare `/manage` ratchet unchanged at 71 |
| `vendor/bin/pint --test`, `php -l`, `git diff --check` on every changed PHP file | Passed |

No browser run, no production or master write, and no real-asset or physical-device acceptance
was performed in this checkpoint. Known limits that remain open:

- The per-request memo is **inert under PHPUnit** (`runningInConsole()` is true for the whole
  suite), so it is covered by a unit test that installs a `Request` by reflection rather than by
  an integration test through the real HTTP kernel.
- The walk defaults cover a **null** column only; a row written directly with
  `walk_radius_m = 0` still produces an empty station list.
- The optional camera is **preventive**: a `[0,0]` centre already written into
  `area_guide_countries.profile` by an earlier no-change save of Hong Kong or the UAE is not
  repaired. Check that column on the shared master before or at cutover.
- Every registry backend file (config, models, services, repository, controller, requests,
  command, both migrations, the JSON source and the tests) is still **untracked in git** and must
  be committed together with the map slice and the production cutover below.

## Reference usage

Read [CoursesController](/app/Http/Controllers/Main/Portal/CoursesController.php)'s
`areaGuideState()` for full reader content and its locked/unavailable behavior. For
lightweight choices, follow `HandleInertiaRequests` and `useAreaGuideRegistry.js`; do not
import the static country arrays into a new database-mode consumer. For writes, follow
`AreaGuideContentController` -> `SaveAreaGuideRequest` -> `AreaGuideRegistryRepository`:
public UUIDs in routes, explicitly mapped nested input, server-resolved connection.

## Related files

**Backend and configuration**
- `config/area_guide_content.php`, `config/database.php`
- `src/AreaGuide/AreaGuideContent.php`, `AreaGuideCountry.php`, `AreaGuideRegion.php`, `AreaGuideArea.php`
- Avatar library: `src/AreaGuide/AreaGuideAvatar.php`, `Repositories/AreaGuideAvatarRepository.php`, `Services/AreaGuideAvatarPresenter.php`, `app/Http/Controllers/Manage/AreaGuide/AreaGuideAvatarsController.php`, `app/Http/Requests/Manage/AreaGuide/{SaveAvatarRequest,AvatarQueryRequest,DeleteAvatarRequest}.php`
- `src/AreaGuide/Support/{AreaGuideContentConnection,AreaGuideContentAccess,LegacyAreaGuideContent,AreaGuideContentUnavailable}.php`
- `src/AreaGuide/Services/{AreaGuideRegistry,AreaStationFinder}.php`
- `src/AreaGuide/Repositories/AreaGuideRegistryRepository.php`
- `app/Http/Controllers/Manage/AreaGuide/AreaGuideContentController.php`
- `app/Http/Requests/Manage/AreaGuide/{SaveAreaGuideRequest,DeleteAreaGuideRequest}.php`
- `app/Http/Controllers/Main/Portal/{CoursesController,AreaGuideController}.php`
- `app/Http/Requests/Main/AreaGuide/ChatRequest.php`, `app/Http/Middleware/HandleInertiaRequests.php`
- `app/Console/Commands/{ImportAreaGuideRegistry,RenderAreaNarration}.php`
- **Carrying one area between sites:** `app/Console/Commands/{ExportAreaGuideArea,ImportAreaGuideArea}.php` — authored content is written on a machine that can only READ this shared database, so an area travels as a folder and is replayed where the write credentials live. ⚠️ **No id of the source site travels**: a chapter's pictures and a building tab's `<img>` both address an avatar by uuid, and the `?v=` cache buster names the exact media file — the reader's route refuses one that does not match, so carrying the source's pair would 404 every tab picture on the destination, silently. The bundle holds `{{avatar:<slug>}}` tokens and the importer substitutes the destination's own uuid AND media uuid. Panoramas and models are deliberately NOT in a bundle (tens of MB each, and they travel the other way — a developer pulls them down). The import is a **dry run by default**, re-runnable, goes through the repositories so a bundle cannot smuggle in markup the editor refuses, and **checks the schema before writing anything**. Deploy runbook: [area-guide-bangsar-south-deploy.md](/docs/operations/area-guide-bangsar-south-deploy.md).

**Frontend and source import**
- `resources/js/Pages/Manage/AreaGuide/Index.vue`, `Partials/{AreaContentForm,AreaContentFormModal}.vue`
- `resources/js/Components/{DataTable,Portal/PortalEngagementTabs}.vue`, `Layouts/ManageLayout.vue`
- `resources/js/utils/areaGuide/{registry,areaOptions}.js`, `composables/useAreaGuideRegistry.js`
- `resources/js/Components/AreaGuide/{AreaGuidePanel,MalaysiaGuide,MalaysiaMap}.vue`
- `resources/js/Pages/Main/Portal/Lms/Index.vue`, `Road/Partials/{Cards/AnalyzeCard,Beats/HandoffButtons}.vue`
- `resources/js/utils/road/stages.js`, `Pages/Main/Portal/AreaTutorial/Show.vue`
- `resources/js/Pages/Manage/AreaGuide/AreaTutorials/{Index,Show,Partials/AreaTutorialForm}.vue`
- `resources/data/area-guide/legacy-countries.json`, existing country modules and `config/area_guide*.php`

**Migrations, permissions and routes**
- `database/migrations/catalogue/2026_09_13_160000_create_area_guide_registry.php`
- `database/migrations/catalogue/2026_09_19_100000_create_area_guide_avatars.php`, `..._100100_add_avatar_id_to_area_guide_assets.php` (the avatar library — **catalogue** connection, so they run with `--path=database/migrations/catalogue`)
- `database/migrations/2026_09_13_200001_grant_area_guide_permissions.php`
- `src/Auth/Permission.php`, `routes/web.php` (`manage.area-guide.*`)

**Tests**
- `tests/Unit/AreaGuide/AreaGuideRegistry*.php`, `tests/Feature/Manage/AreaGuideContentTest.php`
- `tests/Feature/Main/Portal/AreaGuide/`, `tests/Feature/Manage/AreaGuide/AreaTutorialsTest.php`
- `tests/Support/TestDatabaseGuard.php`, `tests/Unit/TestDatabaseGuardTest.php`, `tests/Feature/DatabaseSafetyTest.php`, `phpunit.xml`
- `resources/js/utils/areaGuide/registry.test.js`, `Components/AreaGuide/{AreaGuideUrl,CountryMap}.test.js`
- `resources/js/Pages/Manage/AreaGuide/Partials/AreaContentFormModal.test.js`, tutorial/Road and `ShowTabs` tests
