# Wayleave ↔ Project Integration

- **Date:** 2026-09-30
- **Status:** Complete
- **Status Date:** 2026-10-01
- **Phases:** 5
- **Phases Complete:** 5 of 5 — all phases tested and confirmed by Dave (1–2 on 2026-09-30,
  3–4 on 2026-10-01 after the 038 column correction; 5 is documentation)
- **Notes:** Promotes the **Wayleave ↔ project/stocklist/account matching on coverage, not
  just polygon intersect** item from `docs/improvement-opportunities.md` (raised 2026-07-18,
  half built 2026-08-15), reduced to its **project** half. Stocklists and accounts stay in
  the backlog — the shared view this plan builds is what they will extend, so doing projects
  first costs them nothing. Both blockers named in that item are gone: `projects.project_premises`
  is populated and self-maintaining (056–059) and `wayleave.wayleave_coverage` is the canonical
  coverage set (042–055), so **both sides of the match are now materialised UPRN sets**.
  No design prototype phase: the tab is a Tabulator panel in an established pattern, and the
  three open design questions were answered by Dave on 2026-09-30 (recorded in **Decisions**).

## Plan Phases

1. ✅ The shared match view — migration 112
2. ✅ Wayleave side — Projects tab reads the view, badge agrees, Status column fixed
3. ✅ Project side — `project_wayleaves_load.php`
4. ✅ Project side — the Wayleave tab
5. ✅ Documentation and backlog

## Problem

A wayleave covers premises by four routes — a drawn polygon, a direct UPRN, an attached
stocklist, a linked Land Registry title — and **all four go through approval**
(`docs/2026-08-13-wayleave-coverage-approvals.md`). But matching between wayleaves and
projects still only sees the first one.

`wayleave_projects_load.php` does `ST_Intersects(projects.geom, ST_Union(wayleave_polygons.geom))`
and nothing else. Its own header comment calls this the interim signal: it replaced a UPRN
approach that depended on `projects.project_premises`, which was unpopulated at the time.
Two consequences:

- **A wayleave with no polygon matches no project at all.** A wayleave defined entirely by
  an attached stocklist, or by a Land Registry title, is invisible to the Projects tab even
  when every premise it covers sits inside a project boundary.
- **A project has no idea a wayleave exists.** There is no reverse surface. The nav tab was
  drafted and commented out — `html_body_projectedit.php:212` still carries
  `<!-- <button class="nav-tab" data-nav="wayleave"> -->`.

The cost of that is not a missing report. In a build programme wayleaves are easy to miss
and expensive to find by hand; a planner who does not know a wayleave encumbers their ground
builds over it. The purpose of this work, in the words of the backlog item, is that
**wayleave awareness becomes a by-product of using the app rather than a separate chore** —
which means the information has to appear on the entity the planner already has open.

Two smaller faults get fixed on the way, both already logged:

- **The Projects tab Status column is always empty** *(backlog, 2026-08-29)*.
  `wayleave_projects_load.php` aliases `ps.project_status_desc AS status`;
  `wayleave_edit_v3.js:1013` reads `field: 'project_status_desc'`. The field never arrives.
- **The Projects tab is not company-scoped** *(found while scoping this plan, 2026-09-30)*.
  It filters `p.is_deleted = false` and nothing else, so a company-2 user opening their own
  wayleave sees the names and statuses of every other company's projects. This is live today,
  not introduced here, but the new tab would double it and both get closed in this plan.

## Decisions

Three questions the backlog explicitly deferred to this plan. Answered by Dave, 2026-09-30.

**1. A user with project access but no wayleave permission sees the full row, unlinked.**
Every column a wayleave-read user gets — as built: name, status, manager, match reason and
premise counts — with the name as plain text rather than a link. (The reference and the two
dates were in the original answer and turned out not to exist as columns at all; see
Phase 3.)
A count with no detail turns a planner's question into a support ticket, which is the
opposite of the point. Note this is a **deliberate widening**: the wayleave list page at
`?do=wayleaves` is gated, and this tab is not. What it does not widen is company scope —
see Phase 3.

**2. The link is decided per row, from record-level permissions.** `permissionSatisfies`
over `getModulePermission(user, 'wayleaves')` first; if that already satisfies read, every
row links and no further query runs. Only when it does not does one query over
`users.user_item_permissions` decide the listed ids individually. This is the only shape in
which a per-record grant works from this tab.

> **This is inert until admin can grant it.** `admin_save.php`'s `itemPermissionAdd()`
> allows `['project', 'account', 'stocklist']` only, so a `wayleave` item permission cannot
> currently be created — `getItemPermission($dbh, $u, 'wayleave', $id)` will always return
> NULL. `opportunity` is missing from that allowlist too, while `opportunity_load.php:56`
> reads it, so this is a pre-existing gap across two modules and not something this plan
> caused. The per-row code is still the right code: it costs one query that only runs for
> users without module permission, and it starts working the day the allowlist is widened.
> **Widening it is out of scope here** — it is a decision about whether wayleaves should
> have record-level grants at all, and it belongs in the admin module. Logged as a backlog
> item in Phase 5.

**3. A premise with `is_valid = false` counts, reported separately.** The premise is inside
this project's boundary; a sub-project being accountable for it does not make the wayleave
stop encumbering the ground. Excluding it would hide a wayleave covering a parent's whole
footprint from the parent and show it only on a child, which is backwards for the person
who has to action it. Reported as two figures using the vocabulary the Premises tab already
uses (CLAUDE.md, **Project Premises**): `is_valid` reads as **This Project**, its inverse as
**Sub-projects**.

Two consequences to carry:

- **A wayleave appears on every project in a lineage that holds its premises.** A 1-2-3 chain
  shows it three times. That is correct and consistent with `vw_project_premises_map`, which
  already shows the whole lineage.
- **A contested premise counts against both projects and is not tie-broken.** This needs no
  new rule — it is the rule CLAUDE.md already sets: *"Unrelated overlap is counted in both
  projects and flagged, never tie-broken — picking a winner would hide a boundary error
  behind an arbitrary answer."* The practical effect is that per-project premise counts on a
  wayleave's Projects tab can sum to more than that wayleave's own `count(DISTINCT uprn)`.
  **So the tab must not print a total.** A column footer summing those counts would assert an
  arithmetic that is not true.

## Build

### Phase 1 — The shared match view (migration 112) ✅

> **Tested and confirmed by Dave 2026-09-30.** `db/112_wayleave_project_matches.sql` written as
> specified below. Two assertions were added to the migration beyond the plan: the view is
> probed **filtered** from each key rather than with a bare `LIMIT 1` (an unfiltered probe
> would compute every pair in the database, and a filtered read is the access pattern both
> consumers use), and the partition invariant
> `premises_this_project + premises_sub_project = premises_total` is asserted across the view
> — the 058 precedent. A failure there would mean the grain assumption is wrong, which would
> make every premise figure on both tabs wrong in a way nobody would spot by looking.

One SQL definition, queried from both sides, so no two surfaces can disagree about whether a
wayleave affects a project. `db/112_wayleave_project_matches.sql` creates
`wayleave.vw_wayleave_project_matches`.

**It lives in the `wayleave` schema.** The relation reads from both schemas and has no
natural home by the usual rule. Wayleave owns it because wayleave is the dependent module —
projects worked before wayleaves existed, and the fact being recorded is *what encumbers this
ground*, which is a wayleave fact. It also means an instance with the module disabled has the
view sitting unused in a schema nothing else reads.

**Two arms, each carrying both keys from real columns, `UNION ALL`, then `GROUP BY`.**

This shape is forced by the filterability rule (CLAUDE.md, **Wayleave coverage** and
**Project Premises**): `WHERE wayleave_id = ?` and `WHERE project_id = ?` must *both* reach
the base tables, because both surfaces filter and each filters by a different key. Quals on
`UNION ALL` branches push into each branch, and quals on grouping columns push below the
aggregate, so this shape is filterable from either side.

> **Do not reach for a `FULL JOIN` with a `COALESCE`d key.** It is the obvious shape here,
> because the two arms produce overlapping-but-different key sets and a full join looks like
> the way to reconcile them. It is exactly the trap migration 048 hit: a `COALESCE`d key
> cannot be pushed into, so the view computes every wayleave in the database and filters
> afterwards, and the cost estimate was high enough to trigger JIT for 598ms of a 675ms query.

```sql
CREATE VIEW wayleave.vw_wayleave_project_matches AS
SELECT m.wayleave_id,
       m.project_id,
       bool_or(m.arm = 'boundary')                          AS matched_boundary,
       bool_or(m.arm = 'premises')                          AS matched_premises,
       -- NULL where there is no boundary arm row, which is the honest answer: the
       -- wayleave has no polygon over this project, which is not the same as 0%.
       round((ST_Area(ST_Union(m.overlap_geom))
              / NULLIF(max(m.project_area), 0) * 100.0)::numeric, 1)
                                                            AS overlap_pct,
       count(DISTINCT m.uprn)                               AS premises_total,
       count(DISTINCT m.uprn) FILTER (WHERE m.is_valid)     AS premises_this_project,
       count(DISTINCT m.uprn) FILTER (WHERE NOT m.is_valid) AS premises_sub_project
  FROM (
        -- Arm 1: the wayleave's drawn polygons against the project boundary. Kept because
        -- a wayleave can cover land with no premises on it at all -- a field a route
        -- crosses, a verge -- so an empty coverage set must still be able to match.
        SELECT wp.wayleave_id,
               p.project_id,
               'boundary'::text AS arm,
               NULL::bigint     AS uprn,
               NULL::boolean    AS is_valid,
               -- Repaired HERE and not in the predicate above. ST_Intersects tolerates
               -- an invalid ring; the overlay operations do not, and a boundary drawn
               -- with one self-crossing throws TopologyException and takes the whole
               -- query down. Putting ST_MakeValid in the join condition instead would
               -- lose both gist indexes.
               ST_Intersection(ST_CollectionExtract(ST_MakeValid(p.geom),  3),
                               ST_CollectionExtract(ST_MakeValid(wp.geom), 3)) AS overlap_geom,
               ST_Area(p.geom) AS project_area
          FROM wayleave.wayleave_polygons wp
          JOIN wayleave.wayleaves w ON w.wayleave_id = wp.wayleave_id
          JOIN projects.projects  p ON ST_Intersects(p.geom, wp.geom)
         WHERE wp.is_deleted = false
           AND w.is_deleted  = false
           AND p.is_deleted  = false
           AND p.geom IS NOT NULL

        UNION ALL

        -- Arm 2: the premise overlap. This is the half that did not exist.
        SELECT c.wayleave_id,
               pp.project_id,
               'premises'::text,
               pp.uprn,
               pp.is_valid,
               NULL::geometry,
               NULL::double precision
          FROM wayleave.wayleave_coverage c
          JOIN wayleave.wayleaves w ON w.wayleave_id = c.wayleave_id
          JOIN projects.project_premises pp ON pp.uprn = c.uprn
         WHERE c.state  = 'approved'
           AND pp.state = 'approved'
           AND w.is_deleted = false
       ) m
 GROUP BY m.wayleave_id, m.project_id;
```

Six things in there that are decisions rather than syntax:

- **`count(DISTINCT uprn)`, not `count(*)`.** `wayleave_coverage` is one row per premise per
  claiming source, so a premise claimed by both a polygon and a stocklist is two rows and the
  join multiplies. CLAUDE.md names this; it is the single easiest way to get a wrong number
  here.
- **The premise arm reads the base table, not `vw_wayleave_coverage`.** The view is the right
  read surface for display — it adds the AddressBase address and the approval reference — but
  this arm needs keys only, and pulling `basedata.abp` and `wayleave_approvals` into a count
  is pure waste. `WHERE c.state = 'approved'` is the view's own predicate, so the set is
  identical. **Retained premises are included, correctly**: a retained row is `approved`, and
  a retained premise is simply covered.
- **The premise arm does not filter `projects.projects.is_deleted`, on purpose.** A deleted
  project reports a NULL boundary inside `refresh_project_premises()`, so every one of its
  `source = 'boundary'` rows goes to `state = 'removed'` — `pp.state = 'approved'` already
  excludes it, and the join is saved. CLAUDE.md states this explicitly and gives the caveat
  to respect: it holds because `project_premises.source` has `CHECK (source IN ('boundary'))`
  and the refresh function is its only writer. **If a second source is ever added, revisit
  this arm.**
- **Both arms filter `wayleaves.is_deleted`.** Nothing sets that flag yet — there is no
  wayleave delete endpoint, which is its own backlog item — but `dashboardCompanySql()`
  already filters it and the delete will be built. Filtering in the view is free now and
  correct then, and doing it in a view rather than an endpoint means it is true for every
  future reader (CLAUDE.md's stated preference).
- **`project_area` is carried in the boundary arm only.** Computing `ST_Area(p.geom)` per
  premise row would be thousands of polygon-area calls per pair for nothing: with no boundary
  arm row the numerator is NULL and `overlap_pct` is NULL regardless. `max()` over one
  non-NULL value per group is exact, because project area is constant per `project_id`.
- **`overlap_pct` stays a percentage of the *project's* area**, as it is today — how much of
  this project's boundary the wayleave touches. It reads naturally from both sides and no
  existing number changes meaning.

**No new indexes are required.** All four access paths are already covered:
`wayleave_polygons_geom_idx` and `projects_geom_idx` (gist) for the boundary arm;
`wayleave_coverage_uprn_idx` and `project_premises_uprn_state_idx` for the premise join;
`wayleave_coverage_wayleave_state_idx` and `project_premises_project_state_idx` for the two
filter directions. Confirm with `EXPLAIN` under both filters in Phase 1 testing rather than
assuming.

Add `COMMENT ON VIEW` stating the grain (one row per wayleave × project pair), that it is
filterable from either key and must stay that way, and that the three premise counts do not
sum across rows because a contested premise counts in every project claiming it.

### Phase 2 — Wayleave side: the Projects tab ✅

> **Tested and confirmed by Dave 2026-09-30.** Six deviations from the spec below, all
> deliberate — the sixth (the Overlap % column) came out of testing itself:
>
> 1. **The two formatters went into `main.js`, not into `wayleave_edit_v3.js`.**
>    `wayleaveMatchReason()` and `wayleavePremiseFigure()` render the same two facts from
>    both ends of one relation, and `main.js` is loaded globally at `index.php:344` before
>    every page's own JS. Copying them into each editor is exactly the drift the
>    shared-renderer backlog item is about, and here it would let the two surfaces describe
>    the same match differently. Phase 4 calls the same two functions.
> 2. **`pgBool()` added alongside them.** A PostgreSQL boolean reaches JSON from PDO as
>    either a real boolean or the string `'t'` depending on the driver build —
>    `getEnabledModules()` in `global_functions.php` carries the same allowlist for the same
>    reason. Tested once in one helper rather than at each call site; without it
>    `matched_boundary` would read as truthy in both states and every row would say
>    *Boundary + premises*.
> 3. **The Projects panel's subtitle was removed, not reworded.** It read *"Projects that
>    share at least one UPRN with this wayleave's confirmed premise list"* — a description
>    of the UPRN matching that was **reverted before it shipped**. The endpoint has matched
>    on boundary overlap alone ever since, so the panel has been promising the wrong thing
>    to users for the whole life of the tab. Migration 112 makes the sentence true, which is
>    precisely why it is now redundant: the new **Matched by** column says which arm applies
>    per row, and a subtitle restating the column beside it is mechanics.
> 4. **The panel heading and the header stat both became "Projects Affected".** Only the
>    header stat was named in the plan; leaving the panel's own `<h5>` saying *Overlapping
>    Projects* would have contradicted the tab it titles.
> 5. **`wayleave_projects_load.php` no longer returns `$e->getMessage()` to the browser.**
>    It logs and emits the generic `'Database Error!'`, which is what
>    `wayleave_badge_counts.php` beside it already did. Not in scope, but the file was being
>    rewritten and leaving a schema-leaking error path in place while touching every other
>    line of it was not defensible.

**`www/fn/wayleave_projects_load.php`** — replace the query body with a read of the view.
Keep the endpoint's existing gates unchanged (`requireModuleAccess($pdo, 'wayleaves',
'wayleaves', 'read')` plus `requireCompanyMatch($pdo, 'wayleave', $id)`).

```sql
SELECT m.project_id, p.project_name, p.project_status_id,
       ps.project_status_desc,
       m.matched_boundary, m.matched_premises, m.overlap_pct,
       m.premises_total, m.premises_this_project, m.premises_sub_project
  FROM wayleave.vw_wayleave_project_matches m
  JOIN projects.projects p ON p.project_id = m.project_id
  LEFT JOIN projects.project_status ps ON ps.project_status_id = p.project_status_id
 WHERE m.wayleave_id = :w
   <company predicate on p>
 ORDER BY m.premises_total DESC, m.overlap_pct DESC NULLS LAST, p.project_id DESC
```

- **The Status column fix** is the `AS status` alias going away. The JS already reads
  `project_status_desc`; returning the column under its own name is the whole change.
- **The company predicate** closes the leak. Reuse `dashboardCompanySql()` from
  `global_functions.php` — one definition, and it already encodes the rule (company 1 sees
  all, everyone else matches exactly, NULL `company_id` is company-1-only). Its name says
  *dashboard* and its body is generic; using it here is preferred over a second copy of a
  security predicate, and the naming is worth a note in the function's docblock rather than a
  rename. Same two lines every caller uses:

  ```php
  $userCompany = userCheckCompany($dbh, $userID);
  $companyID   = (int)$userCompany['company'];
  $companySql  = dashboardCompanySql('p', $companyID);   // no $module argument
  ```

  Do **not** pass the `$module` argument — soft delete is handled in the view, and passing it
  would add a second `is_deleted` predicate against the wrong alias.
- The `ST_Union` / `ST_MakeValid` CTE and its long comment move into the view, so they come
  out of this file. Leave a one-line pointer saying where the matching now lives.
- **Rewrite the file header.** It currently explains why UPRN matching was abandoned, which
  will be the opposite of true.

**`www/fn/wayleave_badge_counts.php`** — replace the second query. It must count the same set
as the tab or the badge and the list disagree, which is the failure the file's own header
warns about.

```sql
SELECT count(*) AS project_match_count
  FROM wayleave.vw_wayleave_project_matches m
  JOIN projects.projects p ON p.project_id = m.project_id
 WHERE m.wayleave_id = :w <company predicate on p>
```

**Rename the response key** `project_overlap_count` → `project_match_count`, and the copy
with it. The count is no longer an overlap count and leaving the old name would make the
label lie. Three JS reads to update (`wayleave_edit_v3.js:375-377`) and one template string:
`html_body_wayleaveedit_v3.php:27` reads *"Overlapping projects:"* and becomes
**"Projects affected:"**.

**`www/js/wayleave_edit_v3.js`** — `wlLoadProjects()` gains columns:

| Column | Source |
|---|---|
| Project | `project_name`, linked as now |
| Status | `project_status_desc` — arrives for the first time |
| Matched by | `matched_boundary` / `matched_premises` → *Boundary*, *Premises*, *Boundary + premises* |
| Premises | `premises_this_project`, with `premises_sub_project` as a second figure where non-zero |

**No Overlap % column** (Dave, 2026-09-30, on testing). The figure was built and rendered
correctly, then cut: what proportion of a project boundary a wayleave touches is not
something a planner acts on. **Matched by** already says a boundary overlap exists, and the
premise counts say how much of the project is affected in units that mean something.

`overlap_pct` stays in the view and in both endpoints' `SELECT` rather than being removed.
Restoring the column is then a JS block, not a migration, and the cost of keeping it is
small: the overlay operations run only for pairs that already matched, not for candidates.
**If it is ever confirmed as permanently unwanted**, dropping it removes `ST_MakeValid`,
`ST_CollectionExtract`, `ST_Intersection` and `ST_Union` from the view entirely — the
boundary arm reduces to a pure `ST_Intersects` existence test and the TopologyException risk
class goes with it. That is the only reason it would be worth a migration.

No column footer and no total row — see **Decisions 3**.

### Phase 3 — Project side: `project_wayleaves_load.php` ✅

> **Corrected 2026-09-30 after a failed first test.** The first build selected
> `w.wayleave_reference`, `w.ecd_date` and `w.signed_date`, none of which exist:
> **migration 038 dropped all five of wayleave's extra statics** — those three plus
> `wayleave_type` and `wayleave_team` — because none has an equivalent on
> `projects.projects`, and a static field hardcodes a business decision into the schema for
> every client permanently. They are **admin-created dynamic fields now, and 038 creates
> none of them**, so whether a reference or a date exists at all, and what it is called, is
> per instance.
>
> So this was not a typo to patch: a hardcoded column for any of them would have
> re-hardcoded the exact decision 038 removed. The columns are gone from the endpoint and
> the tab, and the spec below is corrected. **The cause was reading the column list off
> migration 032's `CREATE TABLE` at planning time and not tracing it forward** — the
> hazard `docs/` already names about the stale baseline dump, applied to a table rather
> than to the schema file. Every remaining column was then checked against the migration
> history: 038 is the only `DROP COLUMN` on `wayleave.wayleaves`, and `wayleave_status` has
> never been altered.
>
> Surfacing a wayleave's reference or dates here would mean resolving them through the
> field meta and the EAV value tables, per instance — a genuinely different question from
> this tab, and not one worth answering inside it.

> **Tested and confirmed by Dave 2026-10-01.** One deviation: the project access gate is the
> two-tier block copied from `project_load.php:42-53`, **not** `requireModuleAccess()` +
> `requireCompanyMatch()` as written below. Those two would refuse a user holding a
> record-level grant on the project but no `projects` module permission — someone who can
> open the editor perfectly well. The tab would then fail silently on the one page they are
> entitled to be looking at. `requireNotDeleted()` is unchanged.

A new endpoint. **Not a twelfth result set on `project_load.php`**, and that is a decision
rather than a preference: `project_load.php` is one ~860-line response of eleven queries that
blocks the editor render, and this query is spatial. Folding spatial work into the blocking
load is precisely the mistake that was made on the wayleave side and undone — it is why
`wayleave_badge_counts.php` exists at all (CLAUDE.md, **Wayleave coverage**). Do not repeat it
in the other direction.

**Gates, in order:**

```php
requireModuleAccess($pdo, 'projects', 'projects', 'read');   // projects, NOT wayleaves
requireCompanyMatch($pdo, 'project', $project_id);
requireNotDeleted($pdo, 'project', $project_id);
if (!isModuleEnabled($pdo, 'wayleaves')) {
    echo json_encode(['success' => true, 'enabled' => false, 'wayleaves' => []]); exit;
}
```

Gating on **projects** permission and not wayleaves is what implements Decision 1. The
wayleaves module must be *enabled* for the instance; the viewer needs no wayleave permission
to see the list. Returning `enabled => false` rather than a denial matters: the tab should
never have rendered in that case, and a stray request is not an access failure.

**Company scoping is not negotiable and is separate from permission.** A wayleave belonging to
another company must not appear whatever the viewer's wayleave permission — the two rules are
independent and the widening in Decision 1 does not touch this one. Same
`dashboardCompanySql()` predicate, on `wayleave.wayleaves`.

```sql
SELECT m.wayleave_id, w.wayleave_name,
       w.wayleave_status_id, ws.wayleave_status_desc, ws.wayleave_status_style,
       w.wayleave_manager, u.username AS wayleave_manager_name,
       m.matched_boundary, m.matched_premises, m.overlap_pct,
       m.premises_this_project, m.premises_sub_project, m.premises_total
  FROM wayleave.vw_wayleave_project_matches m
  JOIN wayleave.wayleaves w ON w.wayleave_id = m.wayleave_id
  LEFT JOIN wayleave.wayleave_status ws ON ws.wayleave_status_id = w.wayleave_status_id
  LEFT JOIN users.users u ON u.id = w.wayleave_manager
 WHERE m.project_id = :p
   <company predicate on w>
 ORDER BY m.premises_total DESC, m.overlap_pct DESC NULLS LAST, m.wayleave_id DESC
```

`wayleave_status_style` is a style **token key** from `statusStyleRegistry()`, never a colour
— the same contract the editor header badges and the dashboard stage bar use, always keyed on
status id and never on the label.

**Per-row `can_open`** (Decision 2):

```php
$wlPermission = getModulePermission($pdo, $userID, 'wayleaves');
$canOpenAll   = permissionSatisfies($wlPermission, 'read');
// Only users without module permission cost the second query, and only over the ids
// actually listed. Item type is SINGULAR -- 'wayleave', a different namespace from the
// plural module key, as getItemCompanyId()'s map records.
if (!$canOpenAll && $ids) { /* one SELECT over users.user_item_permissions */ }
```

Each row carries `can_open` as a boolean. The endpoint decides it; the JS does not re-derive
it. Expect `can_open` to be uniformly false for a no-permission user until the admin
allowlist is widened — see the callout in **Decisions**.

### Phase 4 — Project side: the Wayleave tab ✅

> **Tested and confirmed by Dave 2026-10-01.** Four notes:
>
> 1. **The fetch and the render are split.** `projectLoadWayleaves()` runs once when the
>    record renders and fills the badge; `projectRenderWayleaves()` builds the Tabulator on
>    first tab open. Tabulator sizes from its container and this one starts in a `d-none`
>    panel — building it there collapses it to a few pixels with the data present but
>    invisible, a trap this codebase has hit before. The split also means opening the tab
>    issues no second request.
> 2. **The race is handled.** Opening the tab before the fetch lands would otherwise leave
>    an empty panel for good, because nothing fires again; the `.done()` handler builds the
>    table itself when the panel is already on screen.
> 3. **The badge hides at zero.** Most projects have no wayleave and a nought on every one
>    of them is noise to read past.
> 4. **The button omits the collapse attributes its neighbours carry.** None of the nine
>    `#collapseNav*` ids they target exists in this file — vestigial from an accordion
>    layout that is gone, with the panel switch done entirely by `data-tab-target`. Copying
>    them would add a tenth. Logged separately, including the false `aria-controls` claim.

**`www/html/html_body_projectedit.php`** — the enablement gate follows `$plannerEnabled`
exactly (lines 42–47), which is the pattern this codebase actually uses for a module
rendering inside a page that has no route of its own:

```php
$wayleavesEnabled = isModuleEnabled($pdo, 'wayleaves');
```

That single flag wraps both the nav button and the panel in `<?php if ($wayleavesEnabled): ?>`.

**No `window.wayleaveAccess` global.** `window.plannerAccess` exists because the planner needs
the read/write distinction in JS to decide which tools to offer; nothing here does. The JS
signal is the presence of the nav button, and the row-level signal is `can_open` from the
endpoint. Adding a global that nothing reads would be a third source of truth for the same
fact.

**The button replaces the commented-out draft at line 212.** Icon `bi-file-earmark-text`,
`data-nav="wayleave"`, `data-tab-target="#nav-wayleave"`, with a count badge like the
wayleave side's.

Placement: after **Premises**, because the two read together — what is inside the boundary,
and what encumbers it. `data-order` is `parseInt`ed and sorted (`project_edit_v2.js:164`), so
there is no spare integer: Wayleave takes **1007** and network → 1008, fibre → 1009,
opportunities → 1010, attachments → 1011, journal → 1012. Six attribute values, mechanical.
*Fallback if that renumber is unwanted:* append at `data-order="1012"` and accept the tab
sitting last. `data-tab-order` on these buttons is inert — line 179 sorts `.project-nav_tab`,
a different class used by the dynamic meta sections — so copy a neighbour's value and do not
reason about it.

**Panel markup**, beside `#nav-premises`:

```html
<div class="content-panel p-3 d-none" id="nav-wayleave">
    <h5>Wayleaves Affecting This Project <span class="small" id="project-wayleave-count"></span></h5>
    <div id="project-wayleave-list"></div>
</div>
```

**`www/js/project_edit_v2.js`:**

- Fire `project_wayleaves_load.php` **after the editor has rendered**, not inside the load —
  same reasoning as `wayleave_badge_counts.php`, and nothing waits on it. Render the badge
  from the response immediately and **cache the rows**, so opening the tab is instant and
  costs no second request. One query per project load, not two.
- Hook the tab into the existing `$('#nav-button-container').on('click', '.nav-tab')` handler
  (line 9603). Note that handler hides `#projectEditSaveButton` for a named list of tabs —
  **add `'wayleave'` to it**, because this panel has nothing to save and offering Save on it
  would be an empty promise.
- Tabulator columns:

| Column | Notes |
|---|---|
| Wayleave | `wayleave_name`, linked to `?do=wayleaveedit&wayleave=` **only when `can_open`** |
| Status | `wayleave_status_desc`, as plain text |
| Manager | `wayleave_manager_name` |
| Matched by | *Boundary*, *Premises*, or *Boundary + premises* |
| Premises | `premises_this_project`, with `premises_sub_project` where non-zero |

No Overlap % column here either — cut on the wayleave side during Phase 2 testing for the
same reason, and a figure not worth showing to a wayleave manager is not worth showing to a
project manager.

- **Empty state:** *"No wayleaves affect this project."* Nothing more — no explanation of how
  matching works, and no instruction to go and create one.
- **The unlinked state gets one line above the table, and only when nothing is linkable:**
  *"Open a wayleave for its full detail — ask an administrator for wayleave access."*
  It says what is unavailable and what to do about it. It does not explain the permission
  model, and it does not appear at all for a user who can open every row.
- Chip vocabulary follows the Premises tab: **This Project** / **Sub-projects**. Do not invent
  parent/child wording here — that was tried on the premises table and got two labels for the
  same fact depending on where you stood (CLAUDE.md, **Project Premises**).

### Phase 5 — Documentation and backlog ✅

- **`CLAUDE.md`** — a short **Wayleave ↔ Project matching** subsection under *Wayleave
  coverage*: the view is the one definition both surfaces read, it is filterable from either
  key and must stay so, `count(DISTINCT uprn)` is required, premise counts do not sum across
  projects, and a wayleave shows on every project in a lineage holding its premises. Also
  amend **Project Premises** to record that `project_premises` now has a cross-module reader.
- **`docs/improvement-opportunities.md`** — four of these were written when this plan was
  created (2026-09-30); only the ticks are outstanding.
  - ✅ *(written)* The matching item's promotion note, leaving stocklists and accounts open.
  - ✅ *(written)* A scope note on the **Projects tab Status column always empty** item
    (2026-08-29) — **tick it** when Phase 2 lands.
  - ✅ *(written, Security)* **`wayleave_projects_load.php` is not company-scoped** — **tick
    it** when Phase 2 lands.
  - ✅ *(written, Security)* **Record-level permissions cannot be granted on wayleaves or
    opportunities** — stays open; it is not this plan's to fix.
  - Add: **the project Wayleave tab is ungated by wayleave permission** — a deliberate
    widening, recorded so it is not later "fixed" as a leak.
  - Note against the existing **Link a wayleave to a project** item that its *"two
    project↔wayleave relationships would share a screen"* warning now has a concrete first
    occupant: the Matched-by column is where an explicit link would have to distinguish
    itself.
- Update this doc's header block: **Status**, **Status Date**, **Phases Complete**, ✅ on
  finished phases.

## Testing checklist

**All phases passed.** Phases 1–2 tested by Dave 2026-09-30; Phases 3–4 tested 2026-10-01,
after a first run of the Wayleave tab failed on three columns migration 038 had dropped
(Phase 3). Two items were changed by testing rather than merely passing: the Overlap %
column was cut from both tabs as not actionable, and the ECD / Signed columns went with the
038 correction. The checklist below is as it was run; items naming those columns have been
corrected to match what shipped.

1. Apply migration 112 and confirm `wayleave.vw_wayleave_project_matches` exists and returns rows.
2. `EXPLAIN` the view filtered `WHERE wayleave_id = <id>` — confirm the filter reaches `wayleave_polygons` and `wayleave_coverage`, and that no sequential scan of `projects.projects` or `project_premises` appears.
3. `EXPLAIN` the view filtered `WHERE project_id = <id>` — confirm the same in the other direction, and that JIT is not triggered.
4. A wayleave defined **only by a drawn polygon** over a project boundary lists that project, with Matched by reading *Boundary*.
5. A wayleave defined **only by an attached stocklist** whose premises sit in a project lists that project — the case that matched nothing before — with Matched by reading *Premises*.
6. A wayleave defined **only by direct UPRNs** inside a project lists that project.
7. A wayleave with **both** a polygon over the project and approved premises inside it appears **once**, with Matched by reading *Boundary + premises*.
8. The Projects tab **Status** column is populated.
9. The Projects tab badge, the *Projects affected* header figure, and the number of rows in the tab all agree.
10. A premise covered by **two sources** of the same wayleave (e.g. a polygon and a direct add) counts **once** in the premise figures.
11. A project whose premises are held by a **sub-project** shows the wayleave, with the count in the Sub-projects figure rather than This Project.
12. A wayleave over a **1-2-3 lineage** appears on all three projects, each with its own split of This Project / Sub-projects.
13. A **contested** premise claimed by two unrelated projects appears against both, and no total is printed anywhere on the tab.
14. A **pending** (not yet approved) coverage premise does **not** cause a match; approving it makes the project appear.
15. Rejecting or removing a previously approved premise removes the match when no other source covers it.
16. A **retained** premise still counts as covered.
17. A **soft-deleted project** appears on no wayleave's Projects tab and in no badge count.
18. As a non-company-1 user, the wayleave Projects tab lists **only** that company's projects.
19. As a non-company-1 user, the project Wayleave tab lists **only** that company's wayleaves.
20. As a company-1 user, both tabs still list records with a NULL `company_id`.
21. With the **wayleaves module disabled**, the project editor shows no Wayleave tab and no gap in the nav.
22. With the module enabled, the Wayleave tab appears in the intended position and the remaining tabs are in their previous order.
23. A user with **projects read and no wayleave permission** sees the full rows — name, reference, status, manager, dates, match reason, premise counts — with names unlinked and the one-line access note above the table.
24. A user with **wayleave read** sees the same rows with every name linked, and the access note absent.
25. A user with **wayleave write** sees the tab identically to a wayleave-read user, and can edit once inside the wayleave editor.
26. Clicking a linked wayleave name opens the correct record.
27. Opening the Wayleave tab hides the Save button, and moving to Main Details brings it back.
28. A project with no matching wayleaves shows the empty-state line and no empty table.
29. The project editor renders at its usual speed with the tab present — the wayleave query does not block it.
30. Opening the Wayleave tab after the page has loaded issues **no** second request.
31. A wayleave with an **invalid drawn polygon** (a self-crossing) loads both tabs without error.
32. Editing a project boundary so it gains or loses premises changes both tabs on next load, with no manual refresh step.

## Risks

- **The view is unfiltered-hostile by design.** It is correct behind either key and wrong as
  an estate-wide read — the same property `vw_project_premises_map` has. Anything later
  wanting "all matches everywhere" needs its own set-based definition, not this view.

- **The premise arm's join order under a `project_id` filter is the planner's call, and on
  dev data it picks the other side.** *(Tested 2026-09-30.)* Filtering by `project_id`, the
  qual **does** reach inside the arm — it lands as `Filter: (project_id = 9)` on the
  `project_premises` scan, so pushdown through `UNION ALL` + `GROUP BY` works from both keys
  and the view's shape is right. But Postgres drives the nested loop from
  `wayleave_coverage` and probes `project_premises` by `uprn`, rather than starting from
  project 9's premises via `project_premises_project_state_idx`. Forcing `enable_seqscan =
  off` did not flip it; it only turned the coverage scan into an index scan.

  **That is the correct plan for this data and not a defect.** The dev database holds 25
  approved coverage rows and a project holds more premises than that, so driving from the
  smaller side is right. Nothing structural blocks the other plan — `wayleave_coverage_uprn_idx`
  and `project_premises_project_state_idx` both exist — and ordinary nested-loop costing
  flips it once one project's premises (hundreds) are fewer than all approved coverage rows
  (tens of thousands), which is the production shape.

  **Re-check on production-scale data**, because it is the plan behind every project editor
  load once Phase 4 ships. If it has not flipped there, the fix is not a view rewrite: it is
  a partial index on `wayleave_coverage (uprn) WHERE state = 'approved'`, which makes the
  premises-driven probe cheap enough to win.
- **`overlap_pct` now aggregates per-polygon intersections rather than intersecting a
  pre-unioned footprint.** The results should be identical; the reason for the change is that
  the union blocked pushdown from the project side. Worth a spot-check against the current
  figure on a wayleave with several overlapping polygons (test 4), because overlapping
  polygons are exactly where the two formulations could differ if the re-union were dropped.
- **Lineage matching multiplies rows.** A wayleave over a deep hierarchy appears on every
  project in it. That is the decision, but on a wide estate a wayleave's Projects tab could be
  long. Ordering by `premises_total` puts the most affected project first; paginate as the
  tab already does.
- **The per-row link is inert on arrival.** Until `admin_save.php`'s item-permission allowlist
  gains `wayleave`, a user without module permission can never have a linked row. The feature
  is correct and dormant; do not diagnose it later as a bug in this code.
- **Cross-module coupling on `project_premises`.** The premise arm depends on
  `state = 'approved'` excluding deleted projects, which holds only while `source` is
  boundary-only and the refresh function is its only writer. Both are enforced by a CHECK
  constraint and by convention respectively; the second is the softer of the two.
