# Wayleave Module Realignment — rebuild to the common module shape

- **Date:** 2026-08-10
- **Status:** ✅ Complete — migrations 031–041 applied, all phases built and tested by Dave
- **Status Date:** 2026-08-13
- **Phases:** 12
- **Phases Complete:** all ✅. Every migration is applied and therefore **immutable** —
  anything wrong with one is corrected by a new migration, never by editing it.
- **Migrations:** 031 module key + status table · 032 entity rebuild · 033 coverage tables
  · 034 field meta + EAV · 035 history triggers · 036 views + `file_uploads.wayleave_id`
  · 037 map-layer registration · 038 statics cut to the projects six, `teams` dropped
  · 039 `cover_image_url` on the projects/opportunity list views · 040 the UNIQUE
  constraints 033 lost · 041 releases dropped, UPRN views reshaped
- **Two rules this work settled**, both worth applying to the next module:
  *if `projects.projects` has no such column, it is not static* — a static field is a real
  column, refused to admin and seeded only by migration, so making one static hard-codes a
  business decision into the schema for every client. And *a lookup table is only justified
  when its values are GeoLynx-controlled or consumed outside the form* — statuses qualify,
  a per-client team list does not.
- **Eight shared-code faults this surfaced**, all pre-existing, all fixed here because the
  wayleave editor exercises the same shared machinery: opportunity uploads never sent an
  `opportunity` key; `opportunity_edit.js` had no attachments renderer; `populateMetaForm()`
  skipped its callback for any column without a matching form input, so `cover_image_url`
  never rendered in any module; `ImageUploader` was defined and instantiated only in
  `project_edit_v2.js` and hardcoded `project`; uploads resolved ids from URL globals rather
  than the hidden input the template renders; `projects.vw_projects_list` and
  `vw_opportunity_list` never selected `cover_image_url`; the admin status "In use" count
  summed records and history and was labelled as records; and
  `dashboardEntityHistoryTable()` was a list of three, so the stale flag had never been
  available to opportunities.
- **Left open, deliberately:** approval across all four coverage sources. Decided
  2026-08-12 that all four should go through it, which cannot be done on the existing
  tables — direct and polygon UPRNs are per-UPRN rows, stocklists and titles are links
  resolving to many premises. It needs the canonical coverage table, and that needs
  investigation before any table shape is proposed. Tracked in
  `docs/improvement-opportunities.md`.

## Plan Phases

0. Pre-flight audit — the four things the repo cannot answer
1. Migration 031 — module key rename and the new status table
2. Migration 032 — entity rebuild (`wayleave.wayleaves`)
3. Migration 033 — coverage, journal and access tables renamed and re-pointed
4. Migration 034 — field meta renamed, EAV rebuilt, static seed re-pointed
5. Migration 035 — history triggers
6. Migration 036 — views and map-layer registration
7. Backend PHP — config maps, endpoints, permissions
8. Editor frontend — `_v3` template, JS, file uploads, CSS
9. List page and route
10. Admin and dashboard surfaces
11. Cleanup and docs

## Problem

Wayleave was built without following the instruction to follow the projects module.
Migration 011 aligned the field-meta tables; everything else still diverges, and each
divergence costs something concrete:

| Divergence | What it costs today |
|---|---|
| `wayleave.agreement_status` is `id` / `description` / `display_order` / `is_active`, no PK | Excluded from `dashboardModuleMap()`, so no dashboard **and** no admin Statuses area — `admin_fields.js:1561-1582` carries a hardcoded "unavailable, pending the wayleave rewrite" message |
| The five EAV value tables use `field_value` / `modified_datetime` / `modified_user`, no `value_id` PK | Cannot be driven by the config-driven `metaFieldLookup()` / `metaFieldValueChanged()` helpers; needs a bespoke trio |
| The five EAV history tables use `history_id` / `field_value` / `history_datetime` / `history_user` | Same, plus migration 018's trigger pattern cannot be applied as written |
| `agreements_history` is **per-field grain** (`history_field`, `history_old_value`, `history_new_value`) | Migrations 017/018 skip wayleave, so history is written by `wayleave_save.php` alone — any other write path leaves no history, and `buildAuditLogQuery()` cannot read it |
| No `getItemPermission()` call anywhere in the module | The record-level permission tier does not exist for wayleave |
| `wayleave_load.php` / `wayleave_save.php` / `wayleave_map_load.php` each build their own `new PDO(...)` | The `app.user_id` GUC `db.php:25` sets never reaches them — load-bearing the moment Phase 5 lands |
| `wayleave_save.php`'s `$staticColumns` omits `company_id` | Company assignment is unreachable from the editor |
| No `normaliseBooleanFieldValue()` on the save path | **Live bug.** `(bool)'f'` is `true`, so a stored false compares equal to a ticked box and the change is discarded. Fixed in the other three on 2026-08-04; wayleave still has it |
| Editor carries a private `renderStaticFields()` / `renderDynamicField()` / `populateAllValues()` trio | A sixth copy of a pipeline that already exists once in `main.js` |
| Five entity views fetched by hardcoded `map_get_v2.php` URLs, absent from `public.map_layers` | `map_get_v2.php` can only gate at *schema* granularity — a user with `wayleave` read can request **any** relation in the schema |
| List page reads a bespoke `wayleave_list_load.php`; audit panel a bespoke `wayleave_audit_log_load.php` | Every other module goes through `data_get.php` + a `vw_*_list` view, and `buildAuditLogQuery()` |
| `wayleave` is the only singular module key and list route | `projects`, `accounts`, `stocklists`, `opportunities` are all plural |

### What is already aligned — do not redo it

- **Field-meta tables** carry the common column naming (migration 011) and hold migration
  015's ten static rows. They are **renamed, not rebuilt**.
- **`fieldMetaModuleConfig()`** has a wayleave entry, so Sections / Categories / Fields in
  Module Management already work, as does `get_update_form.php`.
- **`admin_save.php`** has the reserved-column list and `wayleavename` in its autocomplete
  allowlist; **`admin_load.php`'s `moduleOverview()`** has the label.
- **`getItemCompanyId()`**, **`getAutocompleteLabelMap()`**, **`resolveAutocompleteLabel()`**
  and **`global_search.php`** all know the module.
- **`wayleave.vw_file_uploads`** already filters `file_is_deleted` — the one place wayleave
  is ahead of projects. Keep it.
- **The renderer's degrade-to-text-input default**, once flagged as wayleave's better
  design, is folded into `main.js`'s `renderMetaForm()`. Nothing is lost by adopting it.

### Module-specific functionality — survives unchanged

Not divergence. Every module has some; these are wayleave's. They are re-pointed at the new
names and given the shared `$pdo` and permission tier, and **their logic is not touched**:

- **Premises** — confirmed premises, pending add/remove approval queues, direct UPRNs,
  attached stocklists, linked Land Registry titles.
- **Projects** — overlapping projects by polygon intersect.
- **Map** — polygon draw / edit / delete, and the polygon → UPRN resolution in
  `wayleave_coverage_polygon_save.php`.
- **File delete** — `wayleave_file_delete.php` is the only delete path in the shared upload
  panel anywhere in the app.

## Decisions taken (2026-08-10)

| # | Decision | Choice |
|---|---|---|
| — | Pattern source | **Opportunity module** — already on the shared `renderMetaForm()` / `populateMetaForm()` and the config-driven `metaField*` helpers |
| — | Map behaviour | **Exception:** `project_edit_v2.js` stays canonical; `opportunity_edit.js`'s map block derives from it, so read projects when they disagree |
| — | Map layers | Register the five entity views in `public.map_layers` now, so `map_get_v2.php` can gate per-relation instead of per-schema |
| — | Scope | List page + view, dashboard, history triggers and hierarchy panel all in |
| D1 | Module key and route | **Both go plural** — `?do=wayleave` → `?do=wayleaves`, no alias; `module_key` renamed in `public.app_modules` and `users.role_permissions` |
| D2 | `is_deleted` | **Kept**, and every consumer filters it — including a per-module predicate in `dashboard_load.php`, which no other module needs |
| D3 | `geom` on the entity table | **Created**, unused for now — reserved for a future summary geometry. See below |
| D4 | Existing data | **None retained.** Module never used in production, data is test-only — tables are rebuilt, not migrated |
| D5 | `cover_image_url` | **Added**, with the editor control and an `image_upload.php` map entry |
| D6 | Endpoint files | **Rewritten in place.** See the consequence below |
| D7 | Entity noun | **`agreement` → `wayleave` everywhere** — ~26 tables, 6 views, all FK columns, meta table names and the seeded `field_form_id` values |

### Why `geom` comes across unused

The column is created and nothing writes it (Dave, 2026-08-10) — **reserved for a future
summary geometry**, so that overview maps can show one shape per wayleave rather than every
member polygon. Same call, and the same reasoning, as `opportunity.opportunities.geom`:
starting that work from a column that already exists, with whatever history it has, is
cheaper than adding one later.

This does **not** displace `wayleave_polygons` as the canonical coverage-geometry store —
that was settled on 2026-07-18 because each polygon carries its own attributes and comment,
which a single MultiPolygon cannot; attributes would smear across all member polys. The two
are different jobs: polygons are what the agreement *covers*, `geom` would be a derived
summary of it. Nothing in this plan derives it, and no consumer reads it.

### `bd_manager` becomes `wayleave_manager`

The canonical pattern is `<entity>_manager`, and `dashboardMineSql` keys "Mine" scoping on
it. The **column** renames; the **display label stays "BD Manager"** in the meta row, where
an admin can change it. Schema follows the pattern, UI keeps the domain word.

### The consequence of D6, stated plainly

Rewriting the endpoints in place means **there is no working v2 editor to fall back to**
once Phase 7 lands — the v2 JS calls endpoints whose response shape has changed. The
`_v2`-named front-end files are preserved as a **rollback artifact, not a running fallback**;
restoring them means restoring the endpoints from git too. Testing therefore happens on v3
only, and Phases 7–10 want landing close together.

## Delivery sequence

Migrations first, applied and verified before any PHP or JS lands — the ordering the
opportunity work proved. Three hazards carry over:

1. **`dashboardModuleMap()` is not an inert registration.** Adding the wayleave entry makes
   `dashboard_load.php` walk the module through five per-module maps, and makes
   `statusModuleConfig()` return a config, which switches on the admin Statuses area. All of
   it lands in Phase 10, in one change — a map entry without the five lookups prints
   `Undefined array key` into the JSON body and takes the whole dashboard down.
2. **The module is down from migration 031 until Phase 7 deploys.** Every wayleave endpoint
   names tables that no longer exist. That is inherent to migrations-first; the shortest
   version is to apply 031–036 and deploy the rewritten endpoints in the same sitting.
3. **Any migration that repairs data must assert its own row count.** 025's map-layer UPDATE
   matched zero rows and reported success.

## Build

### Phase 0 — Pre-flight audit

Reduced from the original set: the rebuild makes the volume and referential-health checks
moot. Four things remain that the repo genuinely cannot answer. Read-only.

1. **Map-layer rows** — `SELECT *` (not named columns) from `public.map_layers` where
   `database_schema = 'wayleave' OR url ILIKE '%wayleave%'`, plus
   `map_layer_page_config` for `page_context = 'wayleaveedit'`, plus `max(layer_id)`.
   The relation is stored **three times** and 025 missed the copy inside the `url` string.
2. **Existing triggers** on the `wayleave` schema — 017/018 skipped the module, so Phase 5
   needs to know what is already there before it adds anything.
3. **The current status list** with agreement counts — the new `wayleave_status` table is
   seeded from it, so the client's configured statuses survive the rebuild even though the
   records don't.
4. **Field-meta inventory** — the meta tables are kept, so Phase 4's `field_form_id` updates
   have to name every existing static row, and every dynamic field an admin has created must
   still render and save afterwards.

Opportunity's DDL is **not** audited — migrations 025/026 hold explicit `CREATE TABLE`
statements and 029 is the only later change to them, so the repo is a better source than a
catalog dump.

### Phase 1 — Migration 031: module key rename and the new status table

**Module key** (D1) — `public.app_modules.module_key` and `users.role_permissions.module`
both `wayleave` → `wayleaves`, each with a row-count assertion. These are permission rows:
a missed one silently removes access.

**`wayleave.wayleave_status`** — created fresh to the migration 022 shape, replacing
`agreement_status`:

| Column | Note |
|---|---|
| `wayleave_status_id` | primary key, **no sequence** — `statusSave()` allocates `MAX(id) + 5` |
| `wayleave_status_desc` | |
| `wayleave_status_display_order` | admin-set ordering |
| `wayleave_status_active` | false retires a status; records holding it keep resolving |
| `wayleave_status_style` | style **token key** from `statusStyleRegistry()`, never a colour |

Seeded from Phase 0's status list, plus `Created` per migration 023, which becomes the
**column default** on `wayleave.wayleaves.wayleave_status_id`. That default is what
`statusDefaultId()` reads from the catalog to make the status undeletable and
undeactivatable — keyed on the id, never the label.

Dropping the old `agreement_status_id_seq` is deliberate: no other module's status table has
a sequence, and leaving one means two allocators that will eventually disagree.

### Phase 2 — Migration 032: entity rebuild

Drop `wayleave.agreements` and `wayleave.agreements_history`; create
`wayleave.wayleaves` and `wayleave.wayleaves_history` from explicit DDL — own sequences, own
constraints, named for the module. The opportunity DDL is the reference for *shape*, never
the source of the objects.

`wayleave.wayleaves`, canonical columns first, wayleave-specific after:

```
wayleave_id, parent_wayleave_id, wayleave_name, wayleave_status_id, wayleave_manager,
geom, created_datetime, created_user, modified_datetime, modified_user,
company_id, account_id, cover_image_url, is_deleted,
wayleave_reference, wayleave_type, wayleave_team, ecd_date, signed_date
```

`geom` created but unused (D3). `is_deleted` retained (D2). `cover_image_url` added (D5).
`bd_manager` becomes `wayleave_manager`.

`geom` is typed `geometry(MultiPolygon, 27700)`, matching what the dropped table carried and
what a union of `wayleave_polygons` would produce — so the summary-geometry work, when it
comes, needs no type change.

`wayleave.wayleaves_history` takes the `projects_history` / `opportunities_history` shape:
every entity column plus `history_id`, `history_action`, `history_datetime`, `history_user`
— the full-row snapshot grain `buildAuditLogQuery()` reads. The old per-field table goes
with the drop.

Plus `wayleave.wayleaves_update_modified_fields()` and its `BEFORE UPDATE` trigger, mirroring
`projects.projects_update_modified_fields()`. It belongs with the table, and migration 035's
no-op suppression is written knowing it fires first.

### Phase 3 — Migration 033: coverage, journal and access tables

Eight tables **dropped and recreated** from explicit DDL, their `agreement_id` FK becoming
`wayleave_id` against the new entity table:

| Now | Becomes |
|---|---|
| `agreement_polygons` | `wayleave_polygons` |
| `agreement_polygon_uprns` | `wayleave_polygon_uprns` |
| `agreement_uprns` | `wayleave_uprns` |
| `agreement_stocklists` | `wayleave_stocklists` |
| `agreement_landregistry` | `wayleave_landregistry` |
| `agreement_releases` | `wayleave_releases` |
| `agreement_journal` | `wayleave_journal` |
| `agreement_access_log` | `wayleave_access_log` |

`wayleave.teams` is already correctly named and is untouched.

**Rebuilt rather than renamed, corrected 2026-08-10 after Dave queried it.** The first
draft renamed these in place to preserve their rows, which was incoherent: Phase 2 rebuilds
the entity table *empty*, so every coverage row is orphaned and has to go regardless. A
rename therefore preserved nothing while leaving constraint, index and sequence names still
saying `agreement`, which would then have needed a catalog sweep to clean up. Recreating
gives correct names for free and explicit DDL that can be read.

**It narrows the Phase 4 object-rename sweep but does not remove it.** Dropping a table
takes its constraints and its `OWNED BY` sequences with it, so 032, 033 and 034's EAV
rebuild between them clear 16 sequences and ~45 constraints at no cost. What survives is
everything belonging to the five **renamed** meta tables, because renaming a table renames
neither its constraints nor its sequences: 5 sequences (`agreement_fields_field_id_seq` and
its four siblings) and ~13 constraints (`agreement_fields_pkey`,
`agreement_fields_field_form_id_key`, the section/category FKs, and so on). Left alone,
`wayleave.wayleave_fields` would carry a primary key called `agreement_fields_pkey` — the
half-state D7 exists to prevent, and a failure of testing item 4.

A third category is easy to miss: **dropping a table does not drop its trigger functions.**
Any function the old entity table carried is still in the schema with nothing calling it.
The sweep covers functions as well as constraints and sequences, and is written
catalog-driven so it acts on what is actually there rather than on what the stale baseline
says.

**The principle across Phases 1–4: configuration is preserved, records are rebuilt.**
Statuses are copied (031) and field meta is kept (034) because both are admin's work. The
entity table, these coverage tables and the EAV values are all test records, so all three
are rebuilt.

The migration reports each table's row count before dropping, so what went is on the record,
and **refuses to run if any table carries a column it does not recreate** — the column
definitions come from the stale baseline dump, and an unknown column should stop the
migration rather than be silently discarded.

One thing found while writing it, left alone deliberately: **`wayleave_polygon_uprns` has
no `polygon_id`.** Despite the name it links to the wayleave, not to the polygon that
resolved the UPRN, so "which polygon covers this premise?" is not answerable from it. None
is added here — that is a data-model question for the coverage-table item in
`docs/improvement-opportunities.md`, not something to decide inside a rebuild.

### Phase 4 — Migration 034: field meta rebuilt, EAV rebuilt, statics seeded

> **Corrected by migration 038 (2026-08-11).** 034 seeded **eleven** statics. Five of them
> — `wayleave_reference`, `wayleave_type`, `wayleave_team`, `ecd_date`, `signed_date` — have
> no `projects.projects` equivalent and should never have been static: a static field is a
> real column, refused to admin, seeded only by migration, so those five hard-coded five
> business decisions into the schema for every client and put them beyond admin's reach.
> 038 deletes the meta rows, drops the columns from `wayleaves` and `wayleaves_history`,
> rebuilds the history trigger and list view without them, and removes them from
> `admin_save.php`'s reserved list so admin can recreate them as dynamic fields.
> **The static set is the projects six**: `wayleave_name`, `account_id`,
> `wayleave_manager`, `company_id`, `parent_wayleave_id`, `wayleave_status_id`.
> The rule the boundary follows: *if `projects.projects` has no such column, it is not
> static.* Found by Dave on testing item 10.

**Meta tables renamed** (rows preserved, including migration 015's seed and any dynamic
fields an admin has created):

`agreement_fields` → `wayleave_fields`, `agreement_field_sections` →
`wayleave_field_sections`, `agreement_field_category` → `wayleave_field_category`,
`agreement_field_sub_category` → `wayleave_field_sub_category`,
`agreement_field_dropdown_options` → `wayleave_field_dropdown_options`.

**The static rows must be re-pointed, and this is the easiest thing here to get wrong.**
For a static field `field_form_id` *is* the column name, so every seeded row has to follow
the entity rename or the field silently stops binding:

| `field_form_id` now | Becomes |
|---|---|
| `agreement_name` | `wayleave_name` |
| `agreement_reference` | `wayleave_reference` |
| `agreement_status_id` | `wayleave_status_id` |
| `agreement_type` | `wayleave_type` |
| `bd_manager` | `wayleave_manager` |
| `parent_agreement_id` | `parent_wayleave_id` |
| `wayleave_team`, `account_id`, `ecd_date`, `signed_date` | unchanged |

Plus the autocomplete helper ids: `bd_manager_name` → `wayleave_manager_name`,
`parent_agreement_name` → `parent_wayleave_name`. And
`field_options_source = 'agreement_status'` → `'wayleave_status'`.

The migration asserts it updated exactly the expected number of rows, and fails if any
target form id is already claimed.

**EAV rebuilt** — the ten value and history tables dropped and recreated to the canonical
shape confirmed against `opportunity.opportunity_field_values_*`:

```
value_id (bigint PK, own sequence), field_id, wayleave_id, value,
record_datetime, record_user            -- history adds: history_action
```

with `UNIQUE (field_id, wayleave_id)` on the value tables and FKs to `wayleave_fields` and
`wayleave.wayleaves`. **The save path's `ON CONFLICT` target must name this constraint
exactly** — migration 029 exists because a missing UNIQUE only surfaced at runtime.

Test values are lost, which D4 accepts. Field *definitions* survive.

### Phase 5 — Migration 035: history triggers

`public.fn_wayleaves_history()` on `wayleave.wayleaves` following migration 017, plus one
function and trigger per EAV value table following 018. Same contract: the `app.user_id` GUC
for actor attribution with the row's `modified_user` / `record_user` as fallback, a
malformed setting degrading rather than aborting the write, and no-op suppression.

From here `wayleave_save.php` writes no history itself. Every other write path —
polygon saves, land registry, coverage — starts producing history for free, which the module
has never had. **This is why the shared `$pdo` change in Phase 7 is not optional:** the GUC
travels on the connection `db.php` set it on, and a self-built PDO never has it.

### Phase 6 — Migration 036: views and map-layer registration

Views recreated against the new names:

- **`vw_wayleave_list`** — new, the projects-list shape: entity columns plus status desc and
  style, manager username, account name, parent name, and the `modified_username` join on
  **`modified_user`** (not `created_user` — the bug `vw_opportunity_list` carried). Filters
  `is_deleted = false` at the view, so no consumer has to remember to. Absorbs
  `wayleave_list_load.php`'s premise-count CTEs.
- **`vw_parent_wayleave_links`** — new, mirroring `projects.vw_parent_project_links`.
  Distance is `NULL::numeric`, as accounts, stocklists and opportunities do.
- **`vw_wayleave_polygons`**, `vw_uprns_from_boundary`, `vw_uprns_from_direct`,
  `vw_uprns_from_stocklists`, `vw_uprns_from_titles`, `vw_file_uploads` — recreated against
  the renamed tables and columns. Logic unchanged; `vw_file_uploads` keeps its
  `file_is_deleted` filter.

**Map-layer registration** for the five entity views, as `public.map_layers` rows with
`map_layer_page_config.page_context = 'wayleaveedit'`. **The relation is stored three times**
— `database_schema`, `database_table`, and again inside the `url` string. Set all three and
assert the row count; 025 set two of three and left both opportunity layers registered,
enabled and permanently empty, with `map_get_v2.php` answering "Invalid table name." and
nothing on screen to say so.

### Phase 7 — Backend PHP

**`global_functions.php`**

- `fieldMetaModuleConfig()` — the wayleave entry re-pointed at every new table name, and the
  module key becomes `wayleaves` (D1).
- `buildAuditLogQuery()` — a `wayleaves` entry in `$modules`, `wayleavename` in `$autoMap`
  (present in `resolveAutocompleteLabel()` and `admin_save.php`, missing here), and
  `wayleave_status` in `$statusMap`.
- Delete `checkWayleaveEditField()`, `getWayleaveEditCurrentValue()` and
  `checkWayleaveEditCurrentValueStatic()`; replace with one-line wrappers over
  `metaFieldLookup()` / `metaFieldValueChanged()` / `metaStaticValueChanged()`, as
  `checkOpportunityEdit*()` are. `getWayleaveEditCurrentValue` is the only one of twelve
  such helpers named `get*` — that goes with it.
- `getItemCompanyId()`, `getAutocompleteLabelMap()`, `resolveAutocompleteLabel()` re-pointed.
- **Defined once, in this file** — a helper defined in an endpoint and called from another
  is a fatal that only appears in the browser.
- `dashboardModuleMap()` — **not here.** Phase 10, with its five lookups.

**Endpoints**, rewritten in place (D6), each on the shared `$pdo`, with the two-tier
permission check (`getItemPermission()` + `permissionSatisfies()`) that no wayleave endpoint
has today, and `display_errors` off:

| File | Change |
|---|---|
| `wayleave_load.php` | ~40% deleted — the field-definition, dropdown-options, status-options and teams queries all duplicate `get_update_form.php`. Static row comes from `vw_wayleave_list`. Added: hierarchy CTEs with cycle guards, audit log via `buildAuditLogQuery()`, and a returned `permission`. Counts, journal, files and attached stocklists survive |
| `wayleave_save.php` | Three history INSERT blocks deleted (triggers write it). `$staticFields` **gains `company_id`**. Create path drops the `WHERE description='Draft'` label lookup and lets the default apply. Casts driven from one spec array. **`normaliseBooleanFieldValue()` added** on the write path and both sides of the comparison — the live bug |
| `wayleave_journal_save.php` | Write-permission gate added |
| `wayleave_audit_log_load.php` | **Deleted** — `buildAuditLogQuery()` replaces it |
| `wayleave_list_load.php` | **Deleted** — `data_get.php` + `vw_wayleave_list` replace it |
| The eleven module-specific endpoints | Shared `$pdo`, permission tier, `display_errors` off, new table/column names. **Logic untouched** |

**`data_get.php`** — `wayleave.vw_wayleave_list => true`.

**`get_update_form.php`** — the options-source key becomes `wayleave_status` with the new
columns, and can now return the style column, so the "sources that carry no style column"
branch loses its wayleave case.

**`map_get_v2.php`** — with Phase 6's registration done, the gate moves from the schema
entry to the per-relation check via `map_layer_page_config.page_context` → `routes.php`.
**Derive the page server-side from `geotable`**, never from a caller-supplied parameter, or
the caller simply names a page they hold.

**`image_upload.php`** — a `wayleaves` entity map entry (D5). `serve_image.php` validates
path shape, not an entity allowlist, so it needs no change.

### Phase 8 — Editor frontend

New files; current ones preserved as a rollback artifact (see the D6 consequence above):

| New | Replaces |
|---|---|
| `www/html/html_body_wayleaveedit_v3.php` | `html_body_wayleaveedit.php` |
| `www/js/wayleave_edit_v3.js` | `wayleave_edit_v2.js` |
| `www/js/wayleave_edit_v3_fileuploads.js` | `wayleave_edit_v2_fileuploads.js` |
| `www/css/wayleave_edit_v3.css` | `wayleave_edit_v2.css` |

**Template** follows `html_body_opportunity_edit.php`: hardcoded Main Details pane at
`data-order="-1"` holding only the entity-id hidden input; the tool tabs (Premises, Projects,
Map) given unique ids and high `data-order` so admin sections sort in front; Attachments and
Journal on the projects ids; status badge in the header; cover-image control; per-page toasts
replaced by `glToast()`.

Two things that bite here:
- **Every nav button needs a unique id.** The opportunity template had three sharing
  `id="headingNav-main_details"`, which would have suppressed Main Details three times over
  once the meta existed.
- **`#uploadModal` markup must be present** — `project_edit_v2_fileuploads.js` builds
  `new bootstrap.Modal(document.getElementById('uploadModal'))` at `DOMContentLoaded` and
  throws on `backdrop` without it, killing the whole attachments panel.

**`wayleave_edit_v3.js`** — the private render/populate trio deleted in favour of
`renderMetaForm()` / `populateMetaForm()`; `wayleaveLoad()` / `wayleaveSave()` /
`wayleaveJournalSave()`; `renderHierarchyPanel()`, `initStatusBadge()`, the audit-log
Tabulator, `glToast()`. Read-only users get the form with the save button hidden and inputs
disabled, driven by the returned `permission`. The module-specific panels port across as-is.

**Map — read `project_edit_v2.js`, not `opportunity_edit.js`.** `WL.createMapLayerControls`
and `createOlBaseLayer` (a slim port that deliberately skips vector types) are deleted; the
standard `map_layer_utils.js` path serves all layer types, and Phase 6's registration is what
makes the five entity views arrive through it instead of five hardcoded fetches. Two hazards,
both live because the map sits in a tab hidden on load:

- **OpenLayers measures its container when it renders.** A map built in a hidden pane has
  zero size, and a `focusOnLoad` fit then fits to *that* — nonsense centre and zoom, every
  feature off-screen. It looks exactly like "the layers aren't loading". `updateSize()`
  restores size but does not undo the bad fit, so re-fit on first reveal to the combined
  extent of the loaded vector layers, with spaced retries for the async DB layers.
- **The save button belongs in the header**, hidden on the tool tabs.

**Verify with `node --check`.** Brace counting is unreliable — the committed
`opportunity_edit.js` was already `+2` skewed by braces inside string literals.

### Phase 9 — List page and route

`wayleave_list_v3.js`, `wayleave_list_v3.css`, `html_body_wayleave_list_v3.php` on the
projects-list pattern: `data_get.php` against `wayleave.vw_wayleave_list`, with the projects
column set and filters (status badge, manager, account, company, modified).

Route `?do=wayleave` → `?do=wayleaves`, no alias — an old bookmark 404s, the call already
made for opportunities. Follow the references in `routes.php`, `nav_global.php`,
`nav_admin.php`, the editor's Exit Editor link, and any `?do=wayleave` elsewhere.

### Phase 10 — Admin and dashboard surfaces

**One change, because these are the same switch.** The `dashboardModuleMap()` entry is what
makes `statusModuleConfig()` return a config (switching on the admin Statuses area) *and*
what makes `dashboard_load.php` walk the module.

- `dashboardModuleMap()` — the `wayleaves` entry, using the Phase 1 columns.
- The five per-module lookups **in the same commit**: `dashboardMineSql` (`wayleave_manager`),
  `dashboardNameColumn` (`wayleave_name`), `dashboardHref`
  (`?do=wayleaveedit&wayleave_id=`), `dashboardModuleRecent`, `dashboardModuleListHref`.
- **`is_deleted` filtering** (D2) — wayleave is the only module in the map with a soft-delete
  flag, so the module-walking queries need a per-module predicate. A genuine new branch, not
  a config value; without it the dashboard counts and flags deleted records.
- `admin_fields.js:1561-1582` — **delete the hardcoded "statuses unavailable pending the
  wayleave rewrite" branch.** It is the visible marker for this whole work item.
- `html_body_admin_fields.php` and `admin_load.php` — module key `wayleave` → `wayleaves`
  in `$allowedModules`, `$moduleLabels`, `$moduleIcons` and `moduleOverview()`'s label map.
- `admin_save.php`'s reserved-column list re-pointed at the new entity columns, gaining
  `cover_image_url` and `wayleave_manager`, so no admin-created field can shadow one.
- The dashboard config tables are keyed by module string with absent rows meaning defaults,
  so **no migration is needed for the dashboard**.

### Phase 11 — Cleanup and docs

**Only after testing signs the work off.** Repoint the routes at the `_v3` files, then delete
the v2 front-end files and the two dead endpoints (`wayleave_field_values_load.php` and
`wayleave_field_values_save.php` — confirmed 2026-08-10 to have no caller in `www/js` or
`www/html`).

**`CLAUDE.md`** — route table, schema list, Field Meta System section, the Admin Structure
paragraph saying wayleave has neither Statuses nor Dashboard, and the
`project_edit_v2_fileuploads.js` route list.

**`docs/unusedfiles.md`** — record the v2 → v3 replacements.

**`docs/improvement-opportunities.md`** — tick with dates: "Align wayleave to the other
modules", its security follow-on, and "Bring wayleave onto the dashboard".

## Testing checklist

Tested in order. Report back by number. Each migration runs in its own transaction and is
only recorded if it succeeds, so a failure rolls back completely and the fixed migration
can just be re-run.

1. `php db/migrate.php` (no flag, dry run) lists exactly 031-036 as pending and nothing
   unexpected.
2. `php db/migrate.php --apply` applies all six with no error, and `public.schema_migrations`
   records all six filenames.
3. `\d wayleave.*` matches `\d opportunity.*` for the entity, status, EAV and history
   tables, and every sequence is owned by a `wayleave.*` table.
4. Nothing in the schema is still named `agreement*` — tables, views, columns, sequences,
   constraints, indexes, functions.
5. The status list survived with its order and active flags, plus a `Created` row.
6. `wayleave_status_id`'s column default is a plain integer, not an expression.
7. Nine foreign keys reference `wayleave.wayleaves` — eight children plus its own parent link.
8. An existing non-admin user still has the wayleave access they had, after the module-key
   rename.
9. The editor opens on an existing record without error.
10. Main Details renders all ten static fields, and a save round-trips.
11. Manager, account and parent autocompletes show labels, not raw IDs.
12. `company_id` saves. This has never worked.
13. No PHP notice or warning appears in any JSON response body.
14. Every dynamic field that existed before the rebuild still renders, saves and reloads.
15. A field of each type — text, int, numeric, date, boolean, dropdown, textarea — created
    in admin, renders, saves and reloads with the right value.
16. Unticking a checkbox persists as false. This is broken today.
17. The status badge shows the right label and style, and updates when the status changes.
18. Editing in the editor writes a history row, attributed to the acting user rather than
    to whoever last edited the record.
19. Saving a polygon also writes a history row. Previously only `wayleave_save.php` did.
20. A save that changes nothing writes no history row.
21. The audit log shows static and dynamic changes, with IDs resolved to names.
22. Journal saves, and file upload, download and delete all work.
23. Cover image uploads and displays.
24. All five map layers return features, not empty collections.
25. The map tab renders at full size and fits to the data on first open.
26. Premises tools unchanged — confirmed list, pending add/remove queues, direct UPRNs,
    stocklist attach/detach, Land Registry titles, overlapping projects.
27. Polygon draw, edit and delete all save, and deleting re-resolves the covered UPRNs.
28. A wayleave-read user is refused a relation that is not registered in `map_layers`.
29. The list loads through `data_get.php`, and modified-by shows the modifier, not the
    creator.
30. `?do=wayleave` is gone, and every in-app link goes to `?do=wayleaves`.
31. Module Management shows all five areas for wayleave, with no "unavailable" message.
32. A status in use cannot be deleted, and `Created` can be neither deleted nor deactivated.
33. Dashboard card, stage bar, Mine scoping and Recently opened all show wayleave.
34. Deleted records are excluded from the list, search, dashboard counts and flags.
35. Read-only permission: the save endpoint refuses, not just the UI.
36. A non-main-company user sees only their company's wayleaves.
37. After a day's use, the server log is clean — nothing still reading an `agreement*` name.


## Risks

- **The module-key rename can lock users out.** `users.role_permissions` rows are data — a
  rename that misses rows silently removes access. Assert the row count in the migration and
  check a non-admin user immediately after applying it.
- **There is no working fallback once Phase 7 lands** (D6). The v2 front-end files are
  preserved but their endpoints are gone; rolling back means restoring code from git *and*
  reversing six migrations. Land Phases 7–10 close together and test on v3 only.
- **`dashboardModuleMap()` takes the whole dashboard down if its five lookups lag.** A PHP
  warning prints into the JSON body and breaks the response for every module. This happened
  on 2026-08-05.
- **The `ON CONFLICT` target after the EAV rebuild.** If it doesn't name the constraint
  migration 034 creates, every dynamic-field save fails at runtime and nothing catches it
  before the browser. Migration 029 exists because of exactly this.
- **Static `field_form_id` values are the rename's silent failure mode.** A meta row left
  pointing at `agreement_name` doesn't error — the field just stops binding, and it looks
  like a UI bug rather than a migration one. Assert the update count.
- **Map layer rows are data, not code**, and the relation is stored three times, one inside a
  URL string. Miss the url copy and the layer is registered, enabled and permanently empty,
  with no error in the repo to find.
- **~26 tables, 6 views and 19 endpoints rename at once.** A missed reference is a hard
  failure rather than a silent drift, which is the right trade — but check the server log
  after the first day of use.
