# Improvement Opportunities

Running backlog of improvements spotted during other work — things worth doing that don't
belong to (or justify) a full plan doc yet. Tick items off as they're completed; if an item
grows into real work, promote it to its own dated plan doc and link it here.

- `[ ]` = open, `[x]` = done. Add new items with the date spotted.
- When an item is completed, move it to the top of the **Completed** section at the
  bottom (newest first, noting its original section) so the top of the document only
  ever lists outstanding work and recent completions are easy to spot.

## Cleanup

- [ ] **Delete the dead `www/html/nav_projectedit_html.php`** *(2026-08-29)* — the
  `projectedit` route sets `'nav_html' => ['']` and `'layout' => '2'`, so this file is
  never included; `grep -rn "nav_projectedit_html" www/` returns nothing. It is actively
  misleading rather than merely unused: it contains `id="projectEditSaveButton"` and an
  Export dropdown that read as the editor's current chrome, and a Delete button was
  written into it by mistake during the project-delete build before Dave spotted that the
  editor has no sidebar. The live header bar is in `html/html_body_projectedit.php`.
  Check `docs/unusedfiles.md` for siblings in the same state while removing it.

## Wayleave

- [ ] **Wayleave soft delete is declared but not wired up** *(2026-08-29)* — noted while
  sweeping projects. `wayleave.wayleaves.is_deleted` has existed since migration 032 and
  nothing sets it: there is no wayleave delete endpoint and `wayleave_save.php` excludes
  the column on purpose. Two places already anticipate it and one does not:
  `dashboardCompanySql()` and both dashboard recent-item queries filter deleted wayleaves,
  but `map_get_v2.php`'s `$softDeleteColumn` map covers `projects.projects` only. Add
  `'wayleave.wayleaves' => 'is_deleted'` there when the wayleave delete is built, and check
  `public.map_layers` for wayleave relations first — the map's contents are data, not code,
  which is exactly what was missed on the projects sweep. The projects build is the
  template: `docs/2026-08-28-project-delete-restore.md`.

- [ ] **Wayleave projects tab: Status column always empty** *(2026-08-29)* — spotted by
  Dave while testing the project delete sweep. `wayleave_projects_load.php` aliases the
  status as `ps.project_status_desc AS status`, while the Tabulator column definition in
  `wayleave_edit_v3.js:1013` reads `field: 'project_status_desc'`. The field never
  resolves, so every row renders blank. One-line fix either end; **change the endpoint's
  alias to `project_status_desc`** rather than the JS, since that is the name
  `projects.vw_projects_list` and the other modules' tables already use, and this endpoint
  has exactly one consumer. Unrelated to soft delete — pre-existing.

## Database / Audit

- [ ] **`map_boundary_update.php` has no permission gate at all — SERIOUS** *(2026-08-29)*
  — found while adding the soft-delete guards (`docs/2026-08-28-project-delete-restore.md`,
  Phase 2b). The endpoint checks `loginCheck('func')` and nothing else: no
  `requireModuleAccess`, no company check, no record-level check. Line 201 then runs
  `update projects.projects set geom = ... where project_id = ?` from a POST parameter, so
  **any logged-in user can rewrite any project's boundary in any company**, which also
  fires the premises refresh and rewrites that project's premise set. Its sibling
  `map_feature_save.php` gates on `requireModuleAccess($pdo, 'projects', 'projects',
  'write')` at line 19; this one appears simply to have been missed. It also did not
  include `global_functions.php` until the delete guard was added, which is probably why —
  none of the shared gate helpers were reachable from it. Wants the `map_feature_save.php`
  gate plus a company/record check, and a look at whether the other boundary-writing
  branches in the same file (cables, ducts, points) need record scoping too.

- [ ] **`RAISE NOTICE` in migrations goes nowhere** *(2026-08-13)* — `db/migrate.php` runs
  migrations through PDO, and the pgsql PDO driver does not surface server notices; that
  needs `pg_last_notice()` from the older `pgsql` extension. So every diagnostic line
  written into a migration — row counts before a destructive step, "verified as X",
  allocated ids — has been discarded. Guards are unaffected: `RAISE EXCEPTION` aborts the
  transaction and the message reaches the runner as the error.
  - Noticed while testing the coverage migrations, which report what they discard before
    dropping tables. That reporting was the whole point and none of it was ever seen.
  - **Options, cheapest first:** have migrations write diagnostics into a table
    `migrate.php` selects and prints after each file; or open a second `pg_connect()`
    handle in `migrate.php` purely to read notices; or accept it and stop writing
    `RAISE NOTICE` in migrations, which is the honest alternative to leaving dead code in
    every one.
  - Worth doing before the next destructive migration: "N rows discarded" that nobody
    can see is indistinguishable from not having checked.


- [ ] **Endpoints open private PDO connections instead of reusing `db.php`'s `$pdo`**
  *(2026-07-19)* — `project_journal_save.php`, `account_journal_save.php`,
  `stocklist_journal_save.php`, `map_boundary_update.php`, `map_feature_save.php`,
  `project_auto_route.php`, `dashboard_load.php` *(added 2026-07-28)* (and possibly others)
  each construct their own
  `new PDO(...)` from the `db.php` credentials even though `db.php` already made
  `$pdo`. Wasteful (extra connections per request) and it means per-connection
  session state — notably the history-trigger `set_config('app.user_id', ...)`
  attribution (docs/2026-07-19-entity-history-triggers.md) — doesn't reach them;
  the triggers fall back to `modified_user`/`record_user` for those paths. Sweep
  them to reuse `$pdo`.
- [x] **`vw_opportunity_list.opportunity_modified_username` always shows the creator**
  — **fixed and tested 2026-08-05**, in the view rebuild that came with the opportunity
  module alignment (migration 025). The alias is now `modified_username` and joins on
  `modified_user`; the list page column was re-pointed with it.
  Original note:
  *(2026-07-28)* — spotted while scoping the dashboard redesign. The view joins
  `users.users um ON (o.opportunity_created_user = um.id)` and then aliases
  `um.username AS opportunity_modified_username`; it should join on
  `o.opportunity_modified_user`. Copy-paste of the `uc` join above it. Anywhere the
  opportunity list shows "modified by" is showing "created by". One-word fix in the view
  definition (needs a migration since the view is in the baseline).
- [x] **`image_upload.php` doesn't set `modified_user`** — **fixed 2026-08-05, awaiting
  testing.** Done while making the endpoint entity-aware for opportunity cover images. It
  now sets `modified_user` and `modified_datetime`, and gained a write-permission gate (it
  previously had none beyond "logged in"). Its `cover_image_url` bind also no longer passes
  `PDO::PARAM_INT` for a string path. This mattered more than the original note suggests:
  with the history triggers live (migration 028 for opportunities, 017 for projects), an
  unattributed write is recorded against whoever last edited the record.
  Original note: *(2026-07-28)* — every other write
  path to the three entity tables sets `modified_user = $userID` in the UPDATE
  (`*_save.php`, `*_journal_save.php`, `map_boundary_update.php:201`,
  `map_feature_save.php:352`, `project_create_boundary.php:69`, `stocklist_create.php:68`);
  `image_upload.php:72` updates `projects.projects set cover_image_url = …` without it, so
  the history row that write produces is attributed to whoever last edited the project, not
  to the uploader. No front-end symptom today — `cover_image_url` is not a static meta field,
  so no audit branch reads it and the row is dropped by every static branch's
  `raw_value <> raw_prev` filter — but the stored attribution is wrong, and it is the one
  path that would break if the audit log ever covered the cover image. One-line fix.

- [ ] **A project deleted outside the app leaves stale validity flags** *(2026-08-28)* —
  `projects.project_premises` cascades its rows away with the project, but the ancestors'
  `is_valid` for those UPRNs is then wrong and no trigger can catch it, because the rows are
  already gone. Acceptable today because **no delete path exists in the application** — no
  endpoint, and no `DELETE FROM projects.projects` anywhere in `www/`. A manual delete must
  be followed by `sql/project_premises_resync.sql`. If a delete feature is ever built it
  needs a `BEFORE DELETE` hook to capture the UPRNs first. (`claimant_count` self-corrects,
  since the deleted project stops appearing in the view — a small argument that deriving was
  the right call.)
- [ ] **`vw_project_premises.hierarchy_role` has no consumer either** *(2026-08-28)* —
  the premises table was built on it and then moved to `is_valid`, because
  `hierarchy_role` is only non-null where an *ancestor* also holds the premise, so the top
  project of a chain went unchipped for premises it counts while the one below it did not
  (Dave, 2026-08-28). The column is still the honest answer to "what is this project's role
  for this premise", and it is what a cross-project view would want, but nothing asks. Drop
  it, or find the screen that needs it — same decision as `vw_premise_claimants` below, and
  worth taking together.
- [ ] **`projects.vw_premise_claimants` has no consumer** *(2026-08-28)* — created by
  migration 058 as the canonical "who claims this premise", but every actual reader turned
  out to be a filtered one, and a whole-table aggregate cannot have a filter pushed into it
  (the 048 lesson). `vw_project_premises`, `stocklist_load.php` and `stocklist_premises_geom`
  all ask the same question correlated or via `LATERAL` instead. It is correct and it is the
  right shape for an estate-wide overlap report, so it is kept rather than dropped — but
  decide whether that report is coming, or drop it.
- [ ] **The accounts premise list is still commented out** *(2026-08-28)* —
  `account_load.php:214-231` holds a disabled `$q4` doing `st_intersects(accounts.geom,
  abp.geom)`. It predates `projects.project_premises` and cannot simply be re-enabled: an
  account has no boundary of its own in the premise model, so "the account's premises" means
  the union over its projects, which needs deciding — and whether that union counts
  `is_valid` rows only, or everything the boundaries contain.

## Field Meta System

- [x] **Numeric and boolean dynamic fields are never saved** — **done and tested
  2026-08-04** (raised 2026-07-19). Commit 3 of the pre-merge sequence. Admin has offered
  `numeric` and `boolean` as data types since commit 1 on the basis that this would land.

  The reported half was right: `project_save.php` / `account_save.php` /
  `stocklist_save.php` collected changed values into `$dynamicValuesNumeric` /
  `$dynamicValuesBool` and exposed both in `$data`, but only text, date and int had write
  blocks. Numeric values were collected, change-checked and then dropped on the floor.

  **Boolean turned out to be broken three times over**, which the note had not caught:
  1. **No write block** — the same fault as numeric.
  2. **The collection `switch` said `case "bool"`** while the stored `field_data_type` is
     `boolean` everywhere else (the `fieldTypeCombinations()` whitelist, the change-check
     helpers, the value table name). It never matched, so boolean values fell through to
     `default` and never reached their bucket at all.
  3. **The change-check cast both sides with `(bool)`.** Every non-empty PHP string is
     truthy except `"0"`, so `(bool)'f'` is `true` — a stored false read back compared equal
     to a ticked box and the change was discarded as "no change". This would have stopped
     the new write block firing even once the first two were fixed.

  Fixed with the six missing upsert blocks (same shape as int; both tables already carry
  the `UNIQUE (field_id, <entity>_id)` that `ON CONFLICT` needs), the `case` corrected with
  `"bool"` kept as a fall-through, and `normaliseBooleanFieldValue()` in
  `global_functions.php` used on the write path *and* on both sides of the change
  comparison. It handles what a checkbox sends (`"on"`), what a dropdown sends (whatever
  option text an admin chose), and what Postgres reads back (`t` / `f`, which `filter_var`
  does not recognise), yielding null for anything unrecognised so an odd option stores as
  unset rather than failing the whole save.

  **`boolean` + `dropdown` was then removed from `fieldTypeCombinations()`** (Dave,
  2026-08-04, after seeing it fail in testing). It could not round-trip: the value is stored
  as a real boolean, comes back from the load endpoint as `t`/`f`, and the editor then tries
  to select an `<option>` whose value is whatever text the admin typed — which never
  matches, so the field loaded blank however it had been saved. Mapping arbitrary option
  text back onto true/false would have meant guessing. It also asked an admin to remember to
  build a two-option list meaning true/false with nothing checking that they had. Boolean is
  now **checkbox only**; a client wanting a Yes/No picker uses a `text` field with a
  dropdown, which stores the literal text and round-trips correctly.
  - **Check for existing rows before assuming there are none:**
    ```sql
    SELECT 'projects' AS m, field_form_id FROM projects.project_fields
     WHERE field_data_type = 'boolean' AND field_input_type = 'dropdown'
    UNION ALL SELECT 'accounts', field_form_id FROM accounts.account_fields
     WHERE field_data_type = 'boolean' AND field_input_type = 'dropdown'
    UNION ALL SELECT 'stocklists', field_form_id FROM stocklists.stocklist_fields
     WHERE field_data_type = 'boolean' AND field_input_type = 'dropdown'
    UNION ALL SELECT 'wayleave', field_form_id FROM wayleave.agreement_fields
     WHERE field_data_type = 'boolean' AND field_input_type = 'dropdown';
    ```
    Any row returned is now a combination admin cannot represent — the Input Type select
    will not offer `dropdown` for it, and `admin_save.php` rejects the pair. Edit each to
    `checkbox` in admin, or delete it if it was only a test.

- [x] **Unticking a checkbox cannot be saved** — **done and tested 2026-08-04**
  (raised the same day while fixing the above). An unchecked HTML checkbox posts nothing at
  all, so its key never appeared in the POST loop the save endpoints iterate, and true could
  never become false. Fixed with the standard companion: a hidden input of the same name
  carrying `0`, emitted immediately before the checkbox, so PHP takes the checkbox's `1`
  when ticked and the hidden `0` when not. It carries the editor's form class because these
  editors serialise by class rather than by form, and jQuery's `serializeArray` includes
  hidden inputs unconditionally while including checkboxes only when checked — which is
  exactly the behaviour this relies on.
- [ ] **Align the module editors to the admin field options** *(2026-08-02)* — **commit 2
  of 3 in the pre-merge sequence: 1. Admin (`docs/2026-08-02-admin-field-meta-management.md`)
  → 2. this → 3. numeric/boolean saving → merge to `main`.**
  **Substantially done and tested 2026-08-04** — the renderer and the populate path (both
  ticked below) were brought forward with commit 3, because a `number` field and a
  `checkbox` rendered as nothing, so there was no way to enter a value and confirm it
  saved. Every input type admin can produce now renders, populates and saves in all three
  editors. **The merge blocker is therefore cleared**: admin can no longer create a field
  configuration the editors cannot handle.
  **What is left is tidy-up, not a blocker** — the `date` + `input` normalisation noted
  below. Keeping this item open for it rather than closing and re-raising.
  - [x] **The defect is the renderer's failure mode, not the two-column model.**
    **done and tested 2026-08-04** — brought forward because commit 3 could not be
    verified without it: a `number` field and a `checkbox` both rendered as nothing, so
    there was no way to enter a numeric or boolean value and confirm it saved.
    `project_edit_v2.js` and the account/stocklist equivalents switched on
    `field_input_type` handling only `dropdown`, `input`, `password2`, `submit2`, with
    `default: input = ''` — an unrecognised input type made the field vanish from the form
    rather than degrade. All three now handle `number`, `textarea`, `date` and `checkbox`,
    and **default to a text input rather than to nothing**, which is the actual fix: a field
    with an unexpected input type now degrades visibly instead of disappearing. The
    data-type passthrough on `input` is kept deliberately — it is why legacy date fields
    render a native picker, and no datepicker library is loaded anywhere.
    `stocklist_edit.js`'s historical `inputarea` spelling is aliased to `textarea` rather
    than left to hit the default.
  - **Caveat when reading that switch:** it has an `else` on it. Autocomplete pairs are
    built by `buildAutocompleteField` and never consult `field_input_type`, so they are
    immune. The rule is: invisible only if not an autocomplete pair, active, and outside
    `dropdown` / `input`.
  - **Wayleave's renderer is the better design and is the target shape.**
    `wayleave_edit_v2.js:370-387` renders a `<select>` whenever the field carries options,
    handles `textarea`/`date`/`checkbox`/`number`, and defaults to a **text input rather
    than nothing**. Note this is the one place alignment runs *towards* wayleave — flag it
    when the wayleave-alignment item below is picked up, rather than flattening wayleave
    down to the projects switch.
  - **Date fields are the concrete case to fix first** *(2026-08-03)* — existing date
    fields are stored as `field_data_type = date` + `field_input_type = input`, which works
    only by the passthrough accident below. The combination map introduced by the admin
    plan targets `date` + `date`, so from commit 1 onward **a date field created in admin
    renders as nothing** until this commit lands, and pre-existing ones show as
    `input (legacy)` in the admin modal. Commit 2 must render `input_type = 'date'`, and
    should then normalise the existing rows to it in a migration so the legacy flag clears.
  - **`input` passes the data type through as the HTML type** (`<input
    type="${field_data_type}">`). That accident is the only reason date pickers work — it
    yields a native `<input type="date">`, and no datepicker library is loaded anywhere in
    the app. The same passthrough makes `int`, `numeric` and `boolean` invalid HTML types
    that fall back to plain text, so there is no real number or checkbox support in three
    of four modules. Any rework must keep native date working.
  - [x] **Matching populate-path fix.** **done and tested 2026-08-04.**
    `project_edit_v2.js:2760` switched on `field_input_type` but its cases were `input`,
    `dropdown`, `date`, `numeric`, `boolean` — the last three are *data types*, so they
    never matched, and they sat after the DOM write reassigning a variable nobody read.
    Now: `checkbox` uses `.prop('checked', fieldValueIsTrue(v))` and everything else falls
    to a `default` that calls `.val()`, since no other widget needs special handling.
    `fieldValueIsTrue()` (`main.js`) exists because a PostgreSQL boolean surfaces as
    `true`/`false`, `'t'`/`'f'` or `1`/`0` depending on driver and PHP version — trusting
    one spelling would mean checkboxes silently failing to tick on a different server.
  - **Still outstanding on this item:** normalising existing `date` + `input` rows to
    `date` + `date` in a migration so they stop showing as `input (legacy)` in the admin
    modal. Purely cosmetic now — both spellings render a native date picker — so it was
    left out rather than bundled into a testing session.
  - **Merging data type and input type into one control was considered and rejected**
    (2026-08-02). The split is real — storage routing vs widget — and five save endpoints
    plus the `check*` helpers depend on `field_data_type`. Both stay as separate columns
    and separate dropdowns.
- [ ] **New `encrypted` dynamic field data type** *(2026-07-17)* — likely its own plan
  doc: crypto choices + key management need proper design. Dynamic values are currently
  stored plaintext in their native types (`*_field_values_text/int/numeric/date/boolean`);
  add an `encrypted` type for sensitive values, encrypted at rest. Design points to
  settle when picked up:
  - **Where to encrypt:** PHP-side (libsodium/OpenSSL AES-GCM) preferred over pgcrypto —
    keeps plaintext and keys out of SQL, server logs, and pg_stat statements.
  - **Key management:** key lives outside the repo and webroot (config/env), never in
    the DB; think about rotation (store a key-version alongside each ciphertext).
  - **Storage:** new `*_field_values_encrypted` value table per module (ciphertext +
    nonce), plus the matching history table — history must hold ciphertext too, and
    the audit-trail queries must show a change marker only, never a decrypted value.
  - **Plumbing:** new `field_data_type` option through `admin_fields`,
    `get_update_form.php`, the `check*EditField` / value-change helpers, the save
    endpoints' type switch, and the load endpoints (decrypt on serve).
  - **Limitations to enforce in admin:** encrypted fields can't be searched, sorted,
    used as dropdown/options sources, or autocompleted — the admin UI should disallow
    those combinations at field creation.
  - **Access control:** decide whether viewing decrypted values is gated by an extra
    permission (record-level read may not imply may-view-secret).
- [ ] **Dynamic attributes for prospector spatial features** *(2026-07-18)* — requires its
  own plan doc when picked up. The planner's spatial tables (`prospector.network_points` /
  `network_cables` / `network_duct` / `network_subduct` / `network_structures`) carry
  hard-coded attribute columns (`cable_type`, `cable_ref`, `pianoi_ref`, `cable_status`,
  `customer_cable_id`, `fibre_allocation`; `point_type`, `point_name`, `point_status`,
  `point_address`, `point_fibre_allocation_details`, etc.). Move these to the
  meta-driven approach used for project fields: retain only the basic/structural data in
  the spatial tables (ids, `project_id`, `geom`, topology refs like `a_end`/`b_end`/
  `parent_point_id`, audit columns), serve everything else from `*_fields` meta + typed
  EAV value tables, and **migrate the existing attribute values** into the dynamic setup.
  Needs an admin section like the main module fields pages. Scoping for the plan doc:
  - Per-feature-type meta/value tables vs one shared set keyed by feature type.
  - Which columns are load-bearing "basic" data vs migratable attributes — **decided
    (2026-07-18): the `*_type` columns (`point_type`, `cable_type`, etc.) are
    load-bearing and stay static** (map styling, SLD/topology, routing logic), handled
    like status on projects — real column, `static` meta row, canonical options table /
    `field_options_source`. The `*_status` columns *might* also need to stay static to
    manage build-process aspects — settle in the plan doc.
  - Editor surface — *frontend impact should be minimal to none*: the attribute form is
    already metadata-driven. `fn/map_layer_manager.php` serves each layer with its
    `public.map_layers.editable_fields` jsonb; the editors build the form inputs from
    that array (`{id, property, ...}` objects) and bind values via feature properties
    from the layer GeoJSON (`map_get_v2.php`), written back with `feature.set()` on
    save. So the plan's job is backend composition: for prospector layers, generate the
    `editable_fields` array from the `*_fields` meta tables at layer-serve time (same
    shape), flatten EAV values into the served feature properties, and route saved
    properties to the value tables in the save endpoint. The frontend renderer stays
    as-is. Only parity gap to check: current form supports plain inputs — dropdown/
    typed dynamic fields would be a (deferrable) frontend addition.
  - History: the `*_history` tables must follow the split (attribute history moves to
    value-table history), and existing audit queries with it. — the
  `$.each(data.fields)` switch + options loop is duplicated across `project_edit_v2.js`,
  `account_edit_v2.js`, `stocklist_edit.js`, `opportunity_edit.js`. Differences are
  parameterisable: form CSS class, stocklist's extra `inputarea` case (safe for all),
  opportunity's static-skip/no-DOM-check flags. The per-page section/tab scaffolding stays
  page-owned. Do after phase 8a of `2026-07-15-static-fields-to-meta.md` proves the
  projects module — don't conflate with the HTML deletion.
  - **The shared implementation now exists and is in use** *(2026-08-05)* —
    `renderMetaForm()` and `populateMetaForm()` in `js/main.js`, written for the opportunity
    editor rather than pasting the pipeline a fourth time, and proven by it. This item
    reduces to migrating the other three onto it; do not write a fifth. It already folds in
    the 2026-08-04 fixes (every input type, degrade-to-text default, checkbox hidden
    companion, `fieldValueIsTrue()` on populate) and aliases `inputarea` to `textarea`.
    It also fixes something to carry over during the migration: `project_edit_v2.js` relies
    on the template hardcoding `<div id="category-container-1">`, which only holds while that
    section's id is literally 1 — and section ids differ between environments.
    `renderMetaForm()` appends the container to a hardcoded pane when it is missing.
  - **Same split on the save side** *(2026-08-05)* — `metaFieldLookup()`,
    `metaFieldValueChanged()` and `metaStaticValueChanged()` in `global_functions.php` are
    config-driven from `fieldMetaModuleConfig()`, with `checkOpportunityEdit*()` as one-line
    wrappers. The `check{Project,Account,Stocklist}Edit*()` trio (~180 lines each) are what
    is left to migrate. The generic version also shape-checks the interpolated column name
    in the static comparison, which the three hand-written copies do not.
  - **`main.js` now has eight near-identical autocomplete binder blocks** *(2026-08-05)* —
    six of them (`companyname`, `accountname`, `projectname`, `stocklistname`,
    `opportunityname`, `wayleavename`) differ only in the selector, since each already reads
    its type from `$(this.element).data('autocomplete')`. One binder with a combined
    selector would do, and a ninth copy is the wrong answer next time. Left alone
    deliberately: `main.js` loads on every page, so collapsing six live paths wants its own
    change and its own testing pass.

- [ ] **`vw_file_uploads` doesn't filter `file_is_deleted` in projects / accounts /
  stocklists** *(2026-08-05)* — `public.file_uploads.file_is_deleted` exists and
  `wayleave.vw_file_uploads` respects it; the other three views do not.
  `opportunity.vw_file_uploads` was built filtering it. Latent rather than live today,
  because only `wayleave_file_delete.php` ever sets the flag — so it becomes a real bug the
  moment a delete path is added to the shared upload panel, which is the obvious next
  request for that panel.
- [x] **`total_premises` counts 1 for an entity with no projects** *(2026-08-05, fixed
  2026-08-28)* — fixed by migration 058 as a side effect of
  `docs/2026-08-17-project-premises-uprn-table.md`: `count(*)` became `count(pp.uprn)` in
  `vw_projects_list` and `count(DISTINCT pp.uprn)` in the two opportunity views. Its closing
  question is answered — `vw_projects_list` **did** have the same shape, and was fixed in the
  same migration. Original note — in
  `opportunity.vw_opportunity_list` and `vw_opportunity_projects_output` the premises figure
  is `count(*)` over a chain of LEFT JOINs, so an opportunity with no linked projects still
  produces one row and counts it, while `mdu_premises` / `sdu_premises` / `other_premises`
  correctly return 0 because their `CASE` sees a NULL building type. `count(a.uprn)` is the
  fix. Carried across verbatim by migration 025 rather than changed inside a migration: it
  alters a number people may have been reading. Worth checking whether
  `vw_projects_list`'s equivalent has the same shape.
- [ ] **A module's icon is declared in six places, one in a different icon set**
  *(2026-08-06)* — adding opportunities needed `fa-binoculars` in `nav_global.php`,
  `nav_admin.php`, `html_body_admin_fields.php`, `html_body_admin_users.php` and
  `admin_home.js`, plus the Bootstrap Icons spelling `binoculars` in `dashboard.js` because
  that page is bi-only. Each miss surfaces cosmetically, on one page, only when someone
  looks — two were found by testing rather than by grep. One source keyed by module, served
  to both icon sets, would end it.

## Autocomplete / UX

- [x] *(Wayleave)* **Wayleave coverage inputs need lookups, not raw typing**
  *(2026-08-12; done 2026-08-14)* — both halves built. Direct UPRNs search Address Base by
  address, postcode or UPRN via a new `premiseaddress` case in `fn/autocomplete.php`, and
  the bulk paste is kept alongside it as intended. Land Registry titles use the existing
  `landregtitle` lookup, which also gained proprietor-name matching and the three
  `landregistry.ccod` indexes it turned out to be missing (`db/046`) — the search had no
  index on `title_number` at all. Original note follows.

  Raised by
  Dave while testing the wayleave realignment. Two of the four coverage sources are
  free-text boxes where the other two are pickers, and the inconsistency is the tell:
  attaching a stocklist uses a `stocklistname` autocomplete, so the same panel teaches
  two different habits.
  - **Direct UPRNs — search by address, resolve to UPRN.** Today `#wl-direct-uprn-input`
    takes UPRNs typed or pasted in, comma/space separated. Nobody knows a UPRN by sight,
    so in practice it means going to another system to look one up. An address
    autocomplete over `basedata.abp` returning the UPRN as the hidden value would match
    how the stocklist field already behaves. Keep the bulk paste — pasting a list from a
    survey is a real workflow — so this is an additional input, not a replacement.
  - **Land Registry titles — autocomplete the title number.** `#wl-landreg-title` is
    free text. `landregtitle` already exists as an autocomplete type in
    `fn/autocomplete.php` and `resolveAutocompleteLabel()`, reading
    `landregistry.ccod` — so this is wiring an existing lookup, not building one.
  - **Tenure is being asserted rather than read.** The `#wl-landreg-tenure` dropdown lets
    the user state Freehold or Leasehold, and that is what gets stored — it is not
    checked against the title. `landregistry.ccod` knows the real tenure; the field
    should be populated from the chosen title and shown read-only, or dropped. As it
    stands the column records an opinion while looking like a fact, which for a legal
    document is the wrong way round.

- [x] **Opportunity editor's hierarchy render is dead code** — **done 2026-08-05, awaiting
  testing.** The copied `stocklistLoad()` is deleted and the module has a real hierarchy:
  `opportunity.vw_parent_opportunity_links`, ancestor/descendant CTEs in
  `opportunity_load.php`, and `renderHierarchyPanel()` into `#child-opportunities`.
  "Worth checking how much else of that copied loader is inert" was the right instinct —
  the answer was roughly 1,270 lines of stocklist form pipeline, plus `stocklistSave()`,
  all now gone. **The cautionary half:** the same block also held the editor's OpenLayers
  setup and DB-layer loading from its own `// Map Code Start` marker onward, which was
  deleted with it and had to be restored. Read to the end of a copied block before
  removing it.
  Original note: *(2026-07-26)* — spotted while
  redesigning the hierarchy panel. `opportunity_edit.js` carries a copy of the stocklist
  editor's `stocklistLoad()` (it posts to `fn/stocklist_load.php`, called at
  `opportunity_edit.js:2610`) and renders the hierarchy into `#child-stocklists` — but
  `html_body_opportunity_edit.php` has no such container, so the call resolves to an empty
  jQuery set and silently does nothing. Either give the opportunity editor its own
  container and a real opportunity hierarchy, or drop the call. Worth checking how much
  else of that copied loader is inert while you're in there.

- [ ] **Latent: inherited-account panels would show raw IDs for static fields** *(2026-07-27)* —
  spotted while adding the "Details from account" panel to the stocklist editor. The
  **static** branch in `project_load.php` / `stocklist_load.php` selects a flagged field's
  `field_form_id` straight off `accounts.accounts`, so `account_status_id` or
  `account_manager` would render as `3` / `17`. Not reachable today: `pass_field_to_*` is
  only settable in the admin field modal, which static rows can't open (`admin_fields.js`
  locks them, `admin_save.php` rejects `field_update`), and no migration sets either flag —
  so the branch never fires. Only matters if statics ever become flaggable; four of the
  five seeded account statics (db/013) are ID-bearing. Cheap when it does: those meta rows
  already carry `field_autocomplete_type` / `field_options_source`, so route the branch
  through `resolveAutocompleteLabel()` rather than hand-rolling joins.

## Frontend / Assets

- [ ] **The Project Premises layer cannot show an adjacent project's premises**
  *(2026-08-31)* — raised by Dave while scoping
  `docs/2026-08-31-project-premises-map-layer.md`. That layer shows the viewing project's
  lineage: itself, its ancestors and its descendants. Open project 2a and a premise held
  only by its sibling 2b is invisible unless their shared parent holds it too, which is
  the case worth flagging — 2b may be right over the fence, and knowing 2a's neighbour
  already has those premises is exactly what stops two projects planning the same street.
  **Widening the lineage to the whole tree is the obvious fix and only half-answers it:**
  an unrelated project next door is exactly as invisible as a sibling, and is the more
  common case, so the real question is adjacency rather than hierarchy. Two shapes worth
  weighing before building either: a fourth `premise_level` for "held by a project in the
  same tree", which is cheap and inherits the existing filter; or a proximity layer keyed
  on the boundary rather than the lineage, which answers the actual question and needs its
  own view and a distance to argue about. Revisit after the layer has been used —
  Decision 2 in that plan doc has the full reasoning.

- [ ] **`minMaxFilterFunction` / `minMaxFilterEditor` are defined three times**
  *(2026-08-28)* — `main.js:1547`, `project_edit_v2.js:3460` and
  `js_strategy_distancetool.js:506` each carry their own copy, and `main.js` loads on every
  page, so which definition is live depends on script order rather than on which file the
  column belongs to. Editing the wrong copy changes nothing and looks like the filter is
  broken. 34 columns across 8 files use them. Collapse to the `main.js` copy and delete the
  other two — the new `numericOperatorFilterFunction` was deliberately put there alone for
  the same reason.

- [ ] **List tables use `height: "75%"`, which can collapse to nothing** *(2026-08-12)* —
  `project_list.js`, `account_list.js`, `stocklist_list.js` and `opportunity_list.js` all
  size their Tabulator with a percentage height. A percentage only resolves when every
  ancestor has a definite height, and nothing in these pages guarantees that — when it
  fails to resolve, Tabulator sizes the holder to zero and the table renders as a few
  pixels with the data present but invisible. Found on the new wayleave list, which is
  structurally identical to the others; `height: "75vh"` fixed it and is what
  `wayleave_list_v3.js` now uses. The other four are working by luck rather than design.
  Sweep them to `vh` — it is a one-word change per file and removes a failure mode that
  looks like a data problem rather than a layout one.

- [ ] **Nothing can delete a wayleave** *(2026-08-12)* — `wayleave.wayleaves.is_deleted`
  exists and every consumer filters it (the list view, global search, the dashboard
  predicate), but no endpoint or UI ever sets it, so it is permanently false. Wayleave is
  the only entity table with a soft-delete flag at all — projects, accounts, stocklists
  and opportunities have none — so this is either a delete feature the module should have
  and the others should get, or a column that should go. Decide which before building
  either. Raised because testing "deleted records are excluded everywhere" could not be
  run: there is no way to produce one.


- [ ] **Attachment panel layout — small rework across all modules** *(2026-08-11)* —
  raised by Dave while testing the wayleave realignment. The panel is shared markup, so a
  change lands in every module at once: `html_body_projectedit.php`,
  `html_body_account_edit_v2.php`, `html_body_stocklistedit_v2.php`,
  `html_body_opportunity_edit.php` and the wayleave v3 template all carry their own copy of
  the card plus the `#uploadModal` block, driven by the shared
  `js/project_edit_v2_fileuploads.js`.
  - **The specific change is still to be specified** — Dave said "slightly change the
    layout" and the detail was not captured at the time. Pin it down before starting, or
    this becomes a guess.
  - Worth doing in the same pass, since the markup is already duplicated five ways:
    the panel is a candidate for a single shared partial rather than five copies, which
    is what makes a "small layout change" a five-file edit today.
  - **Watch:** `project_edit_v2_fileuploads.js` builds
    `new bootstrap.Modal(document.getElementById('uploadModal'))` at `DOMContentLoaded`,
    so any template that loads it must keep `#uploadModal` present or the whole panel
    throws on `backdrop`.
  - While in there: `deleteFile()` in that shared file is a stub that alerts and does
    nothing, and `editFile()` populates a hard-coded "Sample description for file N".
    Only wayleave has a real delete path (`wayleave_file_delete.php`).

- [x] **Opportunity file uploads always failed with "Opportunity ID"** — **fixed
  2026-08-11**, found by Dave testing the wayleave work. `project_edit_v2_fileuploads.js`
  built its FormData with `project`, `stocklist` and `account` only, so `$_POST['opportunity']`
  was never set, `file_upload.php` left `$id` null and rejected every upload. This is what
  the opportunity plan doc's "attachments upload — still untested by Dave" would have
  caught. Fixed by appending all five entity keys and adding the matching `opportunity` and
  `wayleave` cases to the post-upload refresh switch. The endpoint ignores the keys that do
  not match `entity`, because its `is_numeric()` check drops the empty ones.

- [ ] **Sweep `alert()` and the duplicated toast markup onto `glToast()`** *(2026-07-31)* —
  a shared toast helper now lives in `js/main.js` (loaded on every page, after
  `bootstrap.bundle`), added while building the dashboard admin page:
  `glToast('Saved.')`, `glToast('…', 'error')`, also `warning` / `info`. Errors don't
  auto-hide; messages are inserted as text, not HTML.
  - **Retire the per-page toasts.** `#successToast` / `#errorToast` / `#loadingToast` are
    hand-written blocks duplicated across `html_body_projectedit.php`,
    `html_body_opportunity_edit.php`, `html_body_stocklistedit.php`,
    `html_body_stocklistedit_v2.php` and `project_edit.php`, each with its message baked into
    the HTML ("PDF generated successfully!"). That is why nothing else reuses them. Replace
    the JS in `project_edit_v2.js`, `opportunity_edit.js` and `stocklist_edit.js` with
    `glToast()` calls and delete the markup.
  - **Replace `alert()` in the admin pages** — `admin_fields.js`, `admin_users.js`,
    `admin_map_layers.js`, `admin_dist_analysis.js`. `admin_dashboard.js` already uses the
    helper and is the reference.
  - **Watch:** most `alert()` calls are failure paths, and several actions currently report
    *nothing* on success. Adding a success toast is the point of the sweep, not a side effect —
    check each action tells the user it worked.
- [ ] **Stray unmatched `</main>` above the page header on ~14 templates** *(2026-07-28)* —
  spotted while scoping the dashboard redesign. `html_body_dashboard.php:25`,
  `html_body_account_list.php:27` and every other list/admin template close a `<main>` that
  was never opened — nothing in `index.php` or any partial emits an opening tag before
  `load_file($a, 'html')`. Dead markup left over from an earlier layout; browsers ignore it,
  but it makes the templates' structure misleading to read. Delete the leading `</main>` from
  each (the templates that carry two only need the second).
- [ ] **"Main Details" is a hardcoded tab, so admin cannot style it** *(2026-08-03)* —
  **absorbed into `docs/2026-08-03-admin-section-category-organisation.md` (Phase 2).**
  found by Dave while testing the section icon picker. Every other section tab is rendered
  from the `*_field_sections` meta by the editors' loop, but Main Details is static markup
  in all four editor templates — `html_body_projectedit.php:84`,
  `html_body_account_edit_v2.php:84`, `html_body_stocklistedit_v2.php:75`,
  `html_body_wayleaveedit.php:34` — so its icon, label and position are unreachable from
  the admin fields page. Changing its icon there appears to do nothing.
  - **It also uses a different icon library.** The hardcoded tabs carry Bootstrap Icons
    (`<i class="bi bi-card-list">`) while the meta-driven ones now carry FontAwesome
    (`fas fa-*`). Both libraries are loaded (`index.php:202` bootstrap-icons CDN,
    `index.php:206` local FA), so the page mixes two icon sets — one more reason to
    converge on the meta-driven path.
  - **Fix:** seed a Main Details row in each `*_field_sections` table (icon included),
    delete the static tab markup, and let the existing loop render it. The editors already
    skip a section whose `#nav-{ref}` is present, so the changeover has to remove the
    hardcoded pane at the same time or the section renders twice.
  - Overlaps the section/category UI item below — worth doing together.
- [ ] **Admin fields page: rethink section/category organisation UI** *(2026-07-17)* —
  **promoted 2026-08-03 to `docs/2026-08-03-admin-section-category-organisation.md`**, which
  also absorbs the hardcoded "Main Details" item below and the sub-category removal.
  Tracking lives there now. Each module's admin currently has
  four tabs: Fields | Sections | Categories | Sub Categories. Sections + categories
  exist so users can organise fields, but the admin view is confusing: categories are
  per-section rows, so reusable names repeat (wayleave has `Main Details` plus three
  separate `General` rows under Commercial/Legal/Delivery) — reads fine on the actual
  edit pages, baffling in admin. Scope to explore:
  - **Drop sub-categories** — overkill, and already dead weight: referenced only by
    `admin_fields.js`; `get_update_form.php` never serves them and no editor renders
    them. Verify `*_fields` rows hold no sub-category references, then remove the tab
    and (via migration) the four `*_field_sub_category` tables.
  - **Prototype 2+ UI options** before committing. Leading idea: one page per module —
    compact section + category managers side by side, and below them a drag-and-drop
    hierarchy tree (Section → nested categories, possibly fields) for organising:
    `Section1 › Cat1`, `Section2 › Cat3, Cat4`.
  - **Data-model question:** does a category need recreating per section (current
    model), or should categories be reusable entities linked to sections? Affects
    admin UX and the `*_field_category` shape; editor rendering contract
    (`get_update_form.php` → section/category loops) must keep working either way.
  - ~~**Field deletion guard**~~ — **covered by
    `docs/2026-08-02-admin-field-meta-management.md` (Phase 3)**, which extends the guard
    to the `_history` tables and disables the button rather than explaining after the
    click. Nothing left to do here.

## Wayleave Module

- [x] **Align wayleave to the other modules — once and for all** — **done 2026-08-13**
  (raised 2026-07-18), via `docs/2026-08-10-wayleave-module-realignment.md`. Migrations
  031–041: the module key went plural, the entity noun became `wayleave` throughout, and
  the status, entity, coverage, meta and EAV tables were rebuilt to the common shape. The
  editor, list page, admin and dashboard surfaces all run on the shared machinery now —
  `renderMetaForm()`, `renderAttachments()`, `buildAuditLogQuery()`, `data_get.php`,
  `dashboardModuleMap()` — and history is trigger-written rather than hand-rolled in the
  save endpoint.
  **Still open, deliberately:** approval for all four coverage sources, which needs the
  canonical coverage table and is tracked on that item below. Original note: finish
  the realignment so wayleave code is consistent with projects/accounts/stocklists and
  the app reads as one codebase. Migration 011 aligned the field-meta tables; do a full
  divergence audit against the projects template for the rest: endpoint structure and
  naming, save-path conventions (e.g. `wayleave_save.php` doesn't whitelist
  `company_id`), history table shape (`agreements_history` vs the projects
  `history_action`/`history_datetime`/`history_user` shape), editor JS/CSS structure,
  and any remaining bespoke helpers. Align via migrations and code changes, not
  aliases/shims (breakage acceptable — module unfinished). The old superpowers docs
  (`docs/archive/superpowers/`) are the record of *how* it diverged — not a pattern
  source. This is the prerequisite for the much later consolidation of per-module
  functions into global ones serving all modules (see the shared `renderMetaFields()`
  item above as the first instance of that pattern).
  - **Carries a security follow-on** *(2026-07-26)* — the wayleave editor's five entity
    views (`wayleave.vw_agreement_polygons`, `vw_uprns_from_boundary`, `_direct`,
    `_stocklists`, `_titles`) are fetched by hardcoded `map_get_v2.php` calls in
    `wayleave_edit_v2.js` and are absent from `public.map_layers`; `wayleave_edit_v2.js:71`
    loads DB layers for `page_context = 'wayleaveedit'` but deliberately skips vector types.
    Registering them as part of this alignment lets `map_get_v2.php`'s authorization gate
    tighten from *schema* granularity to the individual relation, taking the module from
    `map_layer_page_config.page_context` → `routes.php` instead of from the schema map.
    Derive it server-side from `geotable` — never from a caller-supplied page parameter, or
    the caller simply names a page they hold. Until then a user with `wayleave` read can
    request any relation in the `wayleave` schema, not only those five.
- [ ] **Attach projects to a wayleave — a fifth coverage source** *(2026-07-30; superseded
  2026-08-15)* — **read "Link a wayleave to a project" further down first.** That entry
  carries the design decision this one predates: a copied boundary is preferred to a live
  fifth source, so most of the mechanism sketched below is not the plan any more. The
  coverage table it defers to now exists as `wayleave.wayleave_coverage`, and the naming
  here is pre-realignment (`agreement_*` rather than `wayleave_*`). Original note follows.

  An
  agreement's coverage can today be built from directly-added UPRNs, an attached stocklist,
  an attached Land Registry title, and drawn polygons (whose contained UPRNs are covered
  too). Projects should be attachable the same way. The clear case is a wayleave for a
  single MDU, where the agreement's boundary and premises are a 1:1 match with the project —
  today that has to be re-drawn or re-derived by hand when the project already says it.
  - Two parts, as with every other source: the **link** (which project is attached) and the
    **UPRN resolution** (what that attachment covers). Mirror `wayleave.agreement_stocklists`
    exactly for the link: `wayleave.agreement_projects (id, agreement_id, project_id,
    added_user, added_datetime, is_deleted)`. The resolution then feeds the canonical
    coverage table in the item below as a fifth `source` — a `vw_uprns_from_projects` view
    is only needed if that table doesn't land first.
  - **Resolution is from the project boundary** — `basedata.abp` premises within
    `projects.projects.geom` — and nothing else. That is the same derivation the matching
    item below decides to **persist** in `projects.project_premises` (trigger-refreshed on
    geom change), so read that cache once it exists; until then derive live. Either way the
    basis is the boundary.
  - **Consequence the other four sources don't have: coverage tracks a shape owned by another
    module.** A stocklist attachment is pinned by the stocklist's premises and a polygon by
    the drawn shape, but a project boundary is editable by a planner who has no idea a
    wayleave depends on it — so an agreement's covered premises can change underneath it.
    Two things follow, both decisions for the plan doc: (a) the wayleave editor should say
    the count is boundary-derived and therefore live, not a fixed figure; (b) **what happens
    to a *signed* agreement when the boundary later grows** — a deed presumably covers what
    it covered at signing, so newly-enclosed premises may need flagging as uncovered rather
    than silently joining the set. That is a legal question as much as a data one.
  - Editor: another attach control beside the existing four, and a matching map layer
    ("UPRNs – projects"). Note that needs a **fifth categorical colour** — the restyle
    deliberately left these OpenLayers source colours alone for want of a categorical
    palette (`docs/2026-07-27-field-safe-colour-scheme.md`, Phase 3); a validated four-slot
    set now exists in `docs/2026-07-28-dashboard-redesign.md` Phase 4 and would need a fifth
    slot validated against it.
  - **This is not a reversal of the "no stored link table" decision in the item below.**
    That decision is about *derived* relevance — "agreements you should probably know
    about" — which is computed live from geometry and UPRN overlap. This is *asserted*
    coverage: a user stating that this project is what the agreement covers, which is a
    stored link by definition, exactly like the other four sources. The two compose —
    attachment feeds the coverage-UPRN set that matching then reads.
  - **Unblocks the dashboard's cross-module risk flag**
    (`docs/2026-07-28-dashboard-redesign.md`) — "project ECD is close and its wayleave is
    unsigned". That flag needs an explicit link: a derived match would put false positives
    into a risk queue, which is where they cost the most.
- [ ] **Wayleave ↔ project/stocklist/account matching on coverage, not just polygon
  intersect** *(2026-07-18; half built 2026-08-15)* — promote to its own plan doc when
  picked up.

  **Both blockers are now gone (2026-08-28).** The project side existed only as an
  unpopulated table, which is exactly why `wayleave_projects_load.php:4-7` reverted UPRN
  matching to boundary intersect in the first place. `projects.project_premises` is now
  populated and self-maintaining for every project (migrations 056-058,
  `docs/2026-08-17-project-premises-uprn-table.md`), so both sides of the match are
  materialised UPRN sets and the interim boundary-overlap signal can be replaced. Dave is
  rebuilding the matching **both ways** as its own feature. Two things that plan will have
  to decide, which are new since this item was written: whether a project premise counts as
  matched when it is `is_valid = false` — visible in this project but counted by a
  descendant — and how a contested premise, validly claimed by two projects, reports
  against one wayleave.

  **The canonical coverage set this item specifies now exists.** Migrations 042–055 built
  `wayleave.wayleave_coverage` — one row per premise per claiming source, a premise covered
  if any row is approved — read through `wayleave.vw_wayleave_coverage`. So the "a table,
  not a view" design sketched below is done, including the reasoning about which sources
  materialise, and this item reduces to the **matching** half: read that view instead of
  `ST_Intersects` against the polygon union, so an agreement defined only by a stocklist or a
  title is found. Do not specify a coverage set of its own.

  Two things to carry across when it is picked up. Coverage is one row per premise per
  source, so counts need `count(DISTINCT uprn)`. And the naming below is pre-realignment —
  `agreement_*` throughout, where the tables are now `wayleave_*`.

  Needs the most design care of the two. **Purpose:** in a build programme wayleaves are very easy
  to miss or very time-consuming to identify manually; this integration should surface
  every relevant agreement automatically on the entities planners actually work in, so
  wayleave awareness is a by-product of using the app rather than a separate chore.
  Today `wayleave_projects_load.php` only does
  `ST_Intersects(projects.geom, ST_Union(agreement_polygons.geom))` with an overlap % —
  its own header comment calls it the interim signal (it replaced a UPRN approach that
  relied on the unpopulated `projects.project_premises`). Insufficient: agreements can
  be defined by polygons, direct UPRNs, attached stocklists, and Land Registry titles —
  an agreement with no polygon currently matches nothing. Design sketch:
  - **Canonical coverage-UPRN set per agreement — a table, not a view** *(revised
    2026-07-30)*. The agreement's own record of which UPRNs it covers and why, maintained
    on save/attach rather than recomputed per read. Sources to union: direct
    `agreement_uprns`; polygon-resolved `agreement_polygon_uprns` (populated by
    `wayleave_coverage_polygon_save.php` via `ST_Within(basedata.abp.geom, polygons)`);
    stocklist premises via `agreement_stocklists` → `stocklists.stocklist_premises`;
    titles via `agreement_landregistry.title_number` → TOID (`landregistry.toid_title_map`)
    → UPRN (`basedata.abp.cross_reference`); and attached projects via the item above.
    - **Why a table.** The current model is already inconsistent: two sources materialise
      their UPRNs (`agreement_uprns`, `agreement_polygon_uprns`) and two are links resolved
      live by views (stocklists, titles). Materialising all of them makes one read surface,
      removes the per-load spatial work, and gives the agreement a stable answer to "what
      do I cover" rather than one that depends on which query you asked.
    - **Grain: one row per `(agreement_id, uprn, source, source_ref)`** — so a UPRN covered
      by both a polygon and a direct add is genuinely **two rows**, not a conflict needing
      a priority order. That answers the covered-twice question by not creating it: "is it
      covered?" is `EXISTS`, "how?" is the row set. The decisive argument is **detach**:
      remove a stocklist and you delete that source's rows and see whether any remain —
      with one-row-per-UPRN plus priority, every detach means recomputing which source now
      wins. `source_ref` (which polygon / stocklist / title / project) also makes cascade
      deletes precise and answers "why is this premise in this agreement?", which for a
      legal document will eventually be asked.
    - **Decide: where per-UPRN state lives.** `wayleave_polygon_uprns` is today *both* the
      polygon resolution cache *and* the approval store (`is_assigned`, `is_approved`,
      `approved_user_id`). Either those flags move onto the coverage row or that table stays
      the source of truth for them and the coverage table points at it — but it cannot be
      ambiguous.
      - **DECIDED 2026-08-12 (Dave): the flags move onto the coverage row, and ALL FOUR
        sources go through approval**, not just polygon-derived UPRNs. Raised while testing
        the realignment: there are four ways to add premises and only one of them asked for
        confirmation, the queue sat in a sidebar disconnected from the polygon tools that
        fed it, and nothing on it said it was reviewing polygon UPRNs. Drawing a polygon
        around a premise is as explicit an act as typing its UPRN, so the split could not be
        justified on "the user did not choose these".
      - **This is what makes the coverage table necessary rather than tidy.** Approving all
        four cannot be done on the existing tables: direct and polygon UPRNs are per-UPRN
        rows, but stocklists and titles are LINKS that each resolve to many premises, so
        there is nowhere to put per-premise approval state for them.
      - **PROMOTED 2026-08-13 to `docs/2026-08-13-wayleave-coverage-approvals.md`.**
        The investigation is done and the design is settled. Headline: pending state is
        DERIVED (live resolution diffed against an approved set), so the refresh triggers
        below are not needed — a stocklist gaining premises simply shows up as a pending
        change. That plan produces `vw_wayleave_coverage`, which is the canonical
        coverage set this item and the matching item both need.
      - Original note — **investigate before designing** (Dave, 2026-08-12): do not open
        with a table shape. The refresh triggers below are the real cost and want
        understanding first.
      - `agreement_releases` is no longer a question here: **migration 041 dropped it.**
        Releasing recorded against the wayleave tables meant nothing on its own; a wayleave
        status of Complete tied to a project is the thing that carries meaning.
    - **Refresh triggers — count these honestly, it is the real cost of materialising.**
      Direct add/remove; polygon draw/edit/delete; stocklist attach/detach; title
      attach/detach; project attach/detach. Plus three cross-module ones that are where
      staleness will actually creep in: **stocklist contents changing** elsewhere, a
      **project boundary being edited** in the projects module, and a **`basedata.abp`
      refresh** introducing new-build premises inside existing coverage.
  - [x] **Canonical project-UPRN set** — **DONE 2026-08-28**, delivered by
    `docs/2026-08-17-project-premises-uprn-table.md` (migrations 056-058). The decision
    below is implemented as written: derived from the boundary, persisted not computed,
    maintained by a trigger on `projects.projects` guarded on real geometry change - plus
    a `parent_project_id` arm the original note did not anticipate, which the stored
    `is_valid` flag obliges. Seeded for all existing projects, and
    `sql/project_premises_resync.sql` is the `basedata.abp` re-sync, wired in as step 4 of
    `sql/abp_sync.sql`. Original note kept below for the reasoning. Promoted to its own
    plan doc 2026-08-17. Everything
    below is carried into it, plus the parent/child visible-vs-valid split and
    approval-ready columns. Do not work this bullet directly. Note the plan **unblocks
    the wayleave↔project matching item below**: `wayleave_projects_load.php:4-7` reverted
    UPRN matching precisely because `project_premises` was unpopulated, and Dave is
    rebuilding the matching both ways as a separate feature once premise logging exists.
    Original note — decided: project UPRNs are *always* derived from
    the boundary geom (`basedata.abp` premises within `projects.projects.geom`), but
    **persisted, not computed per load**: for performance, maintain a project-UPRN
    table refreshed in the background whenever a project boundary changes — never
    user-edited. `projects.project_premises (uprn, project_id)` already has exactly
    this shape (it's the table the old UPRN matching abandoned as partially populated —
    auto-derivation fixes that at the root): repurpose it as the derived cache, or
    replace with a fresh table if existing rows are untrustworthy. Refresh mechanism:
    prefer a DB trigger on `projects.projects` geom change (catches *all* write paths —
    editor save, map geometry saves, auto-route, manual SQL — and dovetails with the
    entity-history-triggers item under Database/Audit) over per-endpoint refresh calls.
    Guard it with `WHEN (OLD.geom IS DISTINCT FROM NEW.geom)` — project rows are
    UPDATEd on every ordinary field save, but boundaries change rarely; the premises
    scan must only run on real geometry change. Update frequency was a concern here —
    accepted (2026-07-18) on the basis that boundary edits are infrequent;
    plus a one-off backfill for all existing projects, and a periodic re-sync when
    `basedata.abp` is refreshed (new-build premises appear without any boundary
    changing).
  - **Matching = one shared relation, queried from every side:** wayleave editor lists
    projects (UPRN-set intersection ∪ polygon×boundary intersect, with per-source match
    reasons and counts + the existing overlap %); project editor gets the reverse — a
    new endpoint/panel listing agreements relevant to the project by the same logic.
    One SQL definition so no two surfaces can disagree.
  - **Stocklists:** an agreement is relevant to a stocklist when directly attached
    (`agreement_stocklists` — explicit), or matched: the stocklist's premises UPRNs
    (`stocklist_premises`) intersect the coverage-UPRN set, or spatially — its premises
    (`basedata.abp` geom) / own `stocklists.geom` fall inside agreement polygons.
    Stocklist editor surfaces relevant agreements like the project editor does.
  - **Accounts — two relationship classes, flagged distinctly:** **assigned** — the
    agreement is explicitly the account's (`wayleave.agreements.account_id` already
    exists for this); vs **identified** — derived, via any matched project or stocklist
    belonging to the account (`projects.account_id`, `stocklists.account_id`). The
    account editor lists both with the assigned/identified flag visible; identified
    ones are the "you probably need to know about this" set.
  - **Match logic note:** UPRN intersection is the primary signal; the polygon×boundary
    geometry intersect stays as a complementary signal (each reported as its own match
    reason) because an agreement can cover land with no premises at all (field a route
    crosses, verge) — an empty coverage-UPRN set must still match on geometry.
  - **Decided (2026-07-18):** matches are **computed live on load**, both directions —
    project load checks for wayleaves, wayleave load checks for projects (likewise the
    stocklist/account editors) — no stored link table. And `agreement_polygons` (one
    row per single Polygon) is the **canonical coverage-geometry store**: each polygon
    can carry its own attributes/comment (e.g. map-viewing notes), which a MultiPolygon
    can't — attributes would smear across all member polys. `wayleave.agreements.geom`
    (MultiPolygon) is a legacy leftover: drop it, or keep only as a derived union if
    anything still reads it — handle in the alignment item above.
  - **Decisions for the plan doc:** does `agreement_polygon_uprns.is_approved` /
    `is_assigned` gate matching or just annotate it; do released UPRNs
    (`agreement_releases`) still count as covered (presumably yes); title→UPRN fidelity
    (freehold title spanning many UPRNs, tenure handling, ccod match quality); polygon
    overlap threshold (any intersect vs minimum %); performance against `basedata.abp`
    (spatial/`cross_reference` indexes, view vs materialised — matters more now
    matching runs on every editor load).

- [ ] **Statuses carry no semantics, so every consumer invents its own** *(2026-08-18)* —
  a status is a label plus `*_status_active` (whether it can still be *selected*, migration
  022) plus a style token. What a status *means* lives outside the status: the complete one
  in `public.dashboard_config` (role `complete_status`), the retired-from-work ones in
  `public.dashboard_inactive_statuses`. Both were built for the dashboard and are now being
  read by something else — `docs/2026-08-17-project-premises-uprn-table.md` uses them to
  decide which project owns a contested premise.
  - **Wanted (Dave, 2026-08-18): a semantic class per status** — *active*, *complete*,
    *stopped* (cancelled, cancelled on customer request, cancelled on cost — many statuses
    mapping to one class, with the specific status keeping the reason for reporting), and
    *inactive* (on hold — exclude from monthly dashboard reporting but not abandoned).
    Many-to-one, which is the shape `dashboard_inactive_statuses` already has.
  - **It belongs in the Statuses area of Module Management, not the Dashboard area.** The
    meaning belongs to the status; the dashboard should consume it rather than define it.
    `admin_fields&area=statuses` already exists and already edits these tables.
    `dashboard_inactive_statuses` then becomes derived — or a compatibility view — rather
    than a second place the same fact is stated.
  - **Fully independent of the premises plan** (revised 2026-08-25). That plan originally
    ranked premise claimants by status; it now counts unrelated overlap in both projects and
    flags it instead, so it reads no status at all. Either piece of work can ship first.
  - **Why it is worth doing at all:** the premises work makes a wrong status *visible* —
    a contested premise alert names the projects and the fixes. Richer statuses only pay off
    once something reads them, which is the argument for doing it second rather than first.

- [ ] **Project boundary approval flow** *(2026-08-25)* — approving premise changes that
  follow a project boundary edit. `docs/2026-08-17-project-premises-uprn-table.md` builds the
  table it needs and keeps `state` (whose CHECK already allows `pending` and `rejected`), but
  deliberately defers everything else: `approval_id`, `decided_user`, `decided_datetime` and
  the `projects.project_approvals` table are this item's to add, in its own migration.
  - **Design already settled, do not re-derive:** a decision records its own membership
    (`uprn_added_list` / `uprn_removed_list` — migration 054 had to add this to wayleave
    afterwards); **no `source` / `source_ref`**, since projects have one source unlike
    wayleave's four; **no boundary snapshot column**, because `projects.projects_history`
    already captures `geom` on every change (migration 017).
  - **Switch-on generates one legacy approval PER PROJECT — ~600 rows, `seq = 1`**
    (Dave, 2026-08-25). Not one estate-wide row: 600 is a small table, and keeping the
    approval scoped to a single project preserves the per-entity shape, keeps `project_id`
    `NOT NULL`, and gives every project a self-contained history starting with its own import
    record. Each stamps that project's existing premise rows with its `approval_id`, so no row
    is left unattributed.
    - **Reference format `P-<project_id>-<seq>`** — so the legacy row on project 42 is
      `P-42-1`. Note this deviates from wayleave's `WL<id>-<seq>` (`WL42-7`, no dash after the
      prefix); it is deliberate, so do not "correct" it back for consistency.
    - **A legacy approval is an ORDINARY approval** (Dave, 2026-08-25), not a special
      immutable class. Drawing a new project after the flow ships produces `pending` premises
      that someone approves; the import is that same operation applied in bulk to the projects
      that already exist. Treating it as a distinct kind buys nothing and costs a special case
      in every surface that reads approvals.
      - So **no `legacy` action value**. `action = 'approve'` with `decided_user` set to a
        system user id reads as "System" in the history, distinguishes an import from a
        person's decision without a CHECK change, and answers the `decided_user NOT NULL`
        problem at the same time.
      - **Populate the membership lists.** An earlier note said they could be empty; that was
        wrong. They are what the detail modal reads, and an empty list on `P-42-1` is exactly
        the bug migration 054 was written to fix. Per project the arrays are modest.
    - **Legacy approvals are reversible, and that is the point** — someone only reverses one
      while working on that project. `wayleave_approval_decide.php:187` reverses off
      `approval_id`, not the membership lists, so nothing special is required to support it.
      Reversing an import sends its rows to `COALESCE(prior_state, 'pending')` = **pending**,
      since nothing preceded it — which is the correct meaning: no longer auto-approved,
      awaiting a decision. Do not treat that as a bug and suppress it.
    - Legacy rows arrive `approved`, so they never appear in the pending queue on switch-on.
      That is a consequence of their state, not a rule needing enforcement.
  - Unlike wayleave, the pending queue is a table read (`WHERE state = 'pending'`) rather
    than a diff view — projects have one source and it is materialised.

## Dashboard

- [x] **Bring wayleave onto the dashboard** — **done and tested 2026-08-13.** Migration
  031 gave the status table the canonical shape and the module joined
  `dashboardModuleMap()` with its five per-module lookups. Two things fell out of doing it:
  `dashboardCompanySql()` now carries the soft-delete predicate, because wayleave is the
  only entity table with `is_deleted` and a per-site filter would eventually be missed; and
  `dashboardEntityHistoryTable()` turned out to be a hand-maintained list of three, so the
  stale flag had silently never been available to **opportunities** either — it is derived
  from the module map now. Original note: blocked only by
  `wayleave.agreement_status` using `id` / `description` instead of
  `agreement_status_id` / `agreement_status_desc`. Do that rename as part of the alignment
  item under **Wayleave Module** (six call sites, listed there), then follow the checklist in
  `docs/2026-07-28-dashboard-redesign.md` → *Turning these modules on later*: one entry in
  `dashboardModuleMap()` plus four small lookups and an icon.
  - **Three further divergences to expect**, verified 2026-07-31: `wayleave.agreements` has an
    `is_deleted` column the other three lack (deleted agreements would be counted and flagged);
    the EAV history columns are `field_value` / `history_datetime` rather than `value` /
    `record_datetime`, which flag 2 reads by name; and migrations 017/018 skip wayleave, so its
    history is written by `wayleave_save.php` rather than by triggers — meaning any other write
    path leaves no history, which weakens the stale flag.
- [x] **Bring opportunities onto the dashboard** — **done and tested 2026-08-05.** The
  blockers are gone: the module has field meta, a status table and `opportunity_manager`
  (migrations 024–030, `docs/2026-08-04-opportunity-module-alignment.md`). It is a full
  `dashboardModuleMap()` member with a count card, stage bar, "Mine" scoping and the flag
  registry, plus its own dashboard config area in Module Management. The "decide first"
  question resolved to the count-and-recents card the plan doc wanted, with full status
  support rather than a declared-unsupported entry.
  **The lesson from doing it:** the `dashboardModuleMap()` entry is what makes
  `dashboard_load.php` walk the module, and its five small per-module maps
  (`dashboardMineSql`, `dashboardNameColumn`, `dashboardHref`, `dashboardModuleRecent`,
  `dashboardModuleListHref`) had to change in the same commit. Landing the map entry alone
  produced `Undefined array key "opportunities"`, which printed into the JSON body and took
  the entire dashboard down. `dashboardNameColumn()` now returns null instead of warning.
  Original note: *(2026-07-31)* — blocked by the module having
  no field-meta tables and no status at all. Needs the planned forms work (meta + value tables,
  a status table and `opportunity_status_id`, plus `opportunity_manager` for "Mine"). After
  that the dashboard side is a clean addition with no divergences to work around.
  - **Decide first:** opportunities has *no card* today — it appears only in Recently opened.
    The plan doc says it should have a count-and-recents card. Either add a map entry that
    declares no status/date support, or amend the intent. See the note in the plan doc.
- [ ] **Dashboard flags v2 — and the line not to cross** *(2026-07-31)* — v1 ships four flags
  defined in `dashboardFlagRegistry()` (`www/fn/dashboard_load.php`), with clients able to
  enable/disable them per module and tune their thresholds
  (`public.dashboard_flag_config`, `db/020`). Deliberately simple. Two extensions are likely,
  and one temptation is worth resisting.
  - **Multiple instances of a flag per module.** Today each flag is a singleton bound to the
    one configured ECD field, so a client with both a *Survey Date* and a *Build Complete*
    date can only flag on one. Same builder, several configured instances, each with its own
    field and thresholds. Needs the `dashboard_flag_config` primary key widened from
    `(module, flag_key)` to include an instance — a new migration, since 020 is immutable
    once applied.
  - **Status-scoped flags** — "Plan ECD is due, but only if the project status is Planning".
    A `only_when_status IN (…)` qualifier on an instance would cover it, reusing the status
    lists the config screen already reads.
  - **The temptation: do not grow this into a workflow engine.** Phase-specific dates
    (survey ECD, plan ECD, build ECD) with per-phase rules is what
    `docs/task_system_plan.md` already describes properly — task instances carry their own
    ECD and SLA, and a task only *exists* once the workflow reaches it, so the scoping is
    inherent rather than a condition bolted onto a record-level rule. Adding phase awareness
    to dashboard flags would reimplement a worse version of it, and the two would then
    disagree about what "due" means. **Flags stay record-level; phases belong to the task
    system.** Agreed 2026-07-31 while deciding v1 scope.

## Performance

- [ ] **`vw_projects_list`'s premise CTE is always computed in full** *(2026-08-28)* — the
  `prems` CTE is joined with `LEFT JOIN prems ON p.project_id = prems.project_id`, and an
  outer join forms no equivalence class, so `WHERE project_id = ?` can never be pushed into
  it. Every read computes the counts for all ~900 projects, including the four that want
  exactly one: `project_load.php:82`, `account_load.php:347`, `stocklist_load.php:387` and
  both `dashboard_load.php` joins. Measured at **529ms on 900 projects / 50k premises**
  after migration 058 replaced the spatial join with an equality join (Dave, 2026-08-27) —
  so the list page is now fast and the single-project readers pay 529ms for one row. Fix is
  to make the per-project counts reachable by the filter: a `LATERAL`, or splitting them
  into their own view that the list joins and the editors query directly. Not done in the
  premises plan because it is a change to a shared view four modules read.
- [ ] **`stocklist_load.php:380`'s CTE aggregates every stocklist** *(2026-08-28)* — the
  `a` CTE groups `stocklist_premises` against `project_premises` with no `stocklist_id`
  filter, and the outer query filters afterwards. Vastly cheaper since migration 058 made it
  an equality join rather than `ST_Intersects`, but still the whole table for one stocklist.
  Pushing the filter in needs a second bound parameter, so it is a refactor rather than the
  source substitution that plan allowed itself.

## External API

- [ ] **Read-only extraction API for clients** *(2026-07-17)* — promote to its own plan
  doc when picked up. Client request: pull GeoLynx data (including spatial) into their
  own database for reporting/analysis. The ~50 internal AJAX endpoints are not this —
  session-cookie auth, editor-shaped responses, no versioning/pagination. Scoping so far:
  - **Surface:** separate versioned read-only endpoints (`/api/v1/projects|accounts|
  - **Surface:** separate versioned read-only endpoints (`/api/v1/projects|accounts|
    stocklists|wayleaves`), paginated, with `modified_since` for incremental sync
    (nightly deltas, not full dumps; check EAV value writes bump the parent record's
    `modified_datetime` or deltas will miss field-only changes).
  - **Serialization:** one generic serializer per module flattens static + dynamic
    fields from the `*_fields` meta — admin-created fields appear in exports
    automatically. Spatial output as RFC 7946 GeoJSON FeatureCollections (properties +
    geometry, WGS84) so QGIS/ogr2ogr/PostGIS ingest it directly.
  - **Auth = keys as service users:** each API key acts as a non-interactive user
    hanging off the existing users/roles machinery, so `getModulePermission` + the
    company predicate authorize and tenant-scope every request unchanged. Role
    assignment is how clients control what a key can export.
  - **Self-service key admin (company-scoped page):** clients create/revoke named keys
    (one per consuming system → zero-downtime rotation); a key can never exceed the
    company's own access. Secret shown once, stored hashed, `glx_` prefix, last-used
    timestamp, journaled create/revoke. Rate limiting + per-key access audit log.
  - **Cheaper alternative to weigh per client:** scheduled CSV/GeoJSON export to
    SFTP/S3 may satisfy a single reporting use case without any API build.

## Security

- [ ] **`project_journal_save.php` has no permission check at all** *(2026-08-05)* — it
  verifies only that someone is logged in, then inserts into `projects.project_journal`
  using a caller-supplied `project_id`. Any authenticated user can write a journal note
  against any project, including one belonging to another company — no module permission,
  no record-level check, no company match. **This is a projects-module issue and nothing to
  do with opportunities:** `project_journal_save.php` is referenced only by
  `project_edit_v2.js`, and the opportunity editor posts exclusively to
  `opportunity_journal_save.php`, which was written with the full gate rather than copying
  it. Spotted while writing that file (2026-08-05).
  - Check `account_journal_save.php` / `stocklist_journal_save.php` for the same hole
    before fixing, so it goes in as one change.
  - The file also assembles a `$staticSQL` string it never executes — delete it while in
    there.

- [ ] **Maintenance mode — close the site to all but admins** *(2026-07-31)* — an upgrade is a
  `git pull` followed by `php db/migrate.php`, run back to back, and the app is live to users
  throughout and immediately afterwards. A maintenance flag would let an admin verify an upgrade
  before letting users back in, rather than testing in front of them.
  - **Shape:** follow `public.app_modules` (`db/006`) — a small table read per request, one row,
    written from admin. Something like `public.app_settings (setting_key, setting_value,
    updated_at, updated_user)` so it isn't a single-purpose table; maintenance mode is then a
    row rather than a schema change every time a global toggle is wanted.
  - **Gate:** enforce in `loginCheck()` (`www/fn/login_check.php`) so it covers pages *and*
    AJAX endpoints from one place — every endpoint already calls it. Pages get a maintenance
    screen; endpoints get a JSON refusal rather than a redirect they can't follow. Admins pass
    through, with a persistent banner so nobody forgets it is on.
  - **Decide:** which permission counts as "admin" for the bypass (`admin_fields` is the
    existing de-facto admin gate); whether an already-logged-in non-admin session is kicked out
    or merely blocked at the next request; and whether it is per-deployment or per-company —
    per-deployment is almost certainly right, since the reason for it is a deploy.
  - **Watch:** locking yourself out. The bypass must not depend on anything the upgrade might
    have broken, and there should be a documented way to clear the flag directly in SQL.
- [ ] **`display_errors` is on in JSON endpoints** *(2026-07-28)* — spotted while scoping the
  dashboard redesign. `fn/dashboard_load.php:7-8` sets `error_reporting(E_ALL)` and
  `ini_set('display_errors', '1')` after already sending `Content-Type: application/json`, so
  any PHP notice or warning is printed into the response body: the client sees a JSON parse
  failure rather than the error, and the message (file paths, SQL, connection details) reaches
  the browser. Sweep `www/fn/` for the same pair — this is unlikely to be the only endpoint —
  and leave errors logged, not displayed.

- [ ] **A disabled selected option is dropped from `.serialize()`** *(2026-08-03)* — the
  three editors post with `$(form).find('.<m>-edit-form').serialize()`, which routes through
  jQuery's select val-getter, and that getter skips disabled options. A record sitting on a
  **retired** status therefore omits its status from the POST altogether. Harmless today —
  the save endpoints only write keys they receive, so the stored value survives, which is
  the intended "don't rewrite an old record's history" outcome — but it is accidental, not
  designed. It becomes data loss the day a save path starts nulling whitelisted static
  fields absent from the payload. Same root cause as the editor badge bug fixed in
  `docs/2026-08-03-admin-status-management.md`; native `select.value` has no such rule.
  Validation is unaffected (the editors use native `form.checkValidity()`).

- [ ] **`getDashboardStatusOptions()` still orders statuses by id** *(2026-08-03)* —
  `global_functions.php`. It feeds the admin dashboard config pickers (complete status,
  inactive set). Since migration 022 every other status list honours
  `<m>_status_display_order`: the editor dropdowns (`get_update_form.php`), the Statuses tab,
  and now the dashboard stage strip. This picker is the last one reading id order, so an
  admin who reorders their statuses sees the new order everywhere except here. One line.

- [ ] **List views render status as plain text** *(2026-08-03)* — **parked pending Dave's
  design decision (2026-08-03): he wants to think about what he actually wants here first.
  Don't start it unprompted.** `project_list.js:76`, `:157` and the account/stocklist
  equivalents. Per-status styles now exist and are keyed on status id
  (`statusStyleRegistry()`, migration 022), so the wiring is the easy part; what needs
  deciding is how heavy a colour block reads in a dense Tabulator grid, where a
  full-strength badge on every row competes with the data. Probably a lighter treatment than
  the editor header badge — a dot, or a tinted left edge — rather than the same pill.

## Opportunity Module

- [ ] **Drop the `prospector.*_migrated_20260804` tables** *(2026-08-05)* — the four
  originals (`opportunity`, `opportunity_access_log`, `opportunity_project_link`,
  `opportunity_routes`) were renamed rather than dropped by migration 025 so a missed
  reference would fail loudly and nothing would be lost. They also hold the 19 link rows
  the copy excluded. Drop them once the module has had a few weeks of real use.
- [ ] **Six near-duplicate opportunity premise queries** *(2026-08-28)* — the same premise
  aggregation is written out six times: `opportunity.vw_opportunity_list`,
  `opportunity.vw_opportunity_projects_output`, and two queries each in
  `opportunity_manage.php` and `opportunity_process.php` (summary and per-project in both).
  Migration 058 and the Phase 4 endpoint work had to make the identical edit in all six, and
  the `count(DISTINCT uprn)` decision had to be re-argued in each. Noted while doing exactly
  that, and deliberately not refactored there — the plan allowed itself the source
  substitution only.
- [ ] **`opportunity_manage.php` is still a multi-action endpoint** *(2026-08-05)* — eight
  modes behind one `switch`, against the one-file-per-endpoint convention everywhere else in
  `fn/`. Deliberately left as-is during the alignment (~1,000 lines of working routing and
  selection code, five JS call sites, no functional gain). Its access-log write has already
  moved out to `opportunity_load.php`.
- [ ] **`add_all` / `remove_all` in the opportunity editor call stocklist functions**
  *(2026-08-05)* — `opportunity_manage.php`'s dispatch sends both modes to
  `premiseAddBatch()` / `premiseDeleteBatch()`, which take `$stocklist_id` and `$uprns` and
  operate on stocklist premises, not opportunity project links. The editor's "Remove
  Selected" button posts `remove_all` with a `data-opportunity` attribute. This looks like
  it has never done what the button says. Pre-existing and unrelated to the schema move, so
  it was left alone — but it needs deciding: wire them to the link table, or remove the
  buttons.
- [ ] **`prospector` is still gated behind the `opportunities` permission** *(2026-08-05)* —
  `map_get_v2.php`'s `$schemaModuleMap` maps `prospector` → `opportunities`, from when the
  module owned that schema. What is left there is the network planner's data
  (`network_points`, `network_cables`, `network_duct`, `network_subduct`,
  `network_structures`, `pia_blockages`), which is project-planning data, so the mapping is
  now arguably wrong in both directions: an opportunities-only user can read planner layers,
  and a projects-only user cannot. Left untouched deliberately — this work changed nothing
  about the prospector tables. Settle it with the map-layer authorization tightening already
  noted under the wayleave item.
- [ ] **`opportunity.opportunities.geom` is unused** *(2026-08-05)* — carried across by
  migration 025 with no UI and no derivation. The parked intent was a derived opportunity
  boundary published as a map layer, shelved for two reasons worth keeping: an opportunity
  can span a very large area, so a hull reads as covering many premises that are in none of
  its projects; and generating a presentable hull performed poorly. Dave plans to revisit.

## Docs / Process

- [ ] **Five copies of the map feature popup, six of formatString** *(2026-08-16)* — the
  click-a-feature popup is duplicated in `project_edit_v2.js`, `map_v5.js`,
  `opportunity_edit.js`, `stocklist_edit.js` and `account_edit_v2.js`, each with its own
  `excludedProps` list and its own `formatString` (also in `js_strategy_distancetool.js`,
  and twice in `map_v5.js`). They have drifted: hit tolerance, which columns are hidden, and
  whether an editable layer offers an Edit link all vary.

  `MapLayerUtils.describeFeaturesAtPixel()` is now the shared version — written for
  `wayleave_edit_v3.js`, which had a sixth copy that stopped at the first feature and printed
  raw column names. Collapsing the other five into it is mechanical but touches every map in
  the app, so it wants doing deliberately with each module's excluded-column list carried
  across as the `exclude` option. Same shape as the `renderMetaForm()` collapse already
  listed: opportunity uses the shared renderer, three editors still carry private copies.

- [ ] **A decision should carry its context, captured at the time** *(2026-08-16)* — the
  decision log renders `source` and `source_ref` raw ("polygon 18") while the pending panel
  resolves a proper label ("Polygon #18 (deleted)"). Carrying the live label down would be
  wrong, not just cosmetic: the suffix is CURRENT state, so a decision made while the polygon
  was alive would read as though it had been deleted at the time, and renaming a polygon
  would silently re-label every past decision. A decision is immutable and everything shown
  against it should be what was true when it was made - the same reason `uprn_added_list`
  had to live on the decision in 054.

  Four columns on `wayleave_approvals`, all snapshots: the source's LABEL as at the decision,
  its STATE then (live / deleted / detached / unlinked), and a user-entered NOTE giving the
  reason - which is the thing a legal audit actually wants and which nothing currently
  captures. Dave raised the note requirement alongside the flag (2026-08-16); they are the
  same feature and should land together, since a reject with no recorded reason is the gap
  that matters most.

- [ ] **A rejected premise stays blocked on stocklist and title sources** *(2026-08-15)* —
  the polygon half of this is fixed: `wayleave_coverage_polygon_save.php` discards a
  'rejected' coverage row once the premise leaves the polygon, so shrinking a boundary past
  a refused premise and growing it back proposes it again. A rejection answers a proposal
  and only suppresses it while the source is still making it.
  
  Stocklists and titles have the identical fault with no equivalent hook. A premise refused
  on a stocklist, then deleted from that stocklist and later added back, resolves as
  'rejected' forever — the source view joins any non-'removed' row. There is no wayleave-side
  event to hang the cleanup on, because the stocklist's CONTENTS change in the stocklist
  module (and by hard `DELETE`, see the deletion item), not through anything this module
  calls. Detach/reattach is hookable and is the lesser half of the problem.
  
  Options, none free: clean stale rejections when the Approvals tab loads (a write on a read
  path); a trigger on `stocklists.stocklist_premises`; or record on the rejection what the
  source produced at the time and compare. Worth deciding deliberately rather than by
  default. Note the workaround is reversing the rejection, which needs `wayleaves_approve` —
  so the person who can fix it is not necessarily the person who hits it.

- [ ] **Link a wayleave to a project** *(2026-08-15)* — a wayleave mirrors a project's
  boundary, added the way a stocklist or title is. Wanted, not required by anyone yet, and
  deliberately parked until the four existing coverage sources have had real use. Analysis
  below so it is not re-derived; promote to a plan doc if a client asks.

  **Copy the boundary, do not source from it live.** Two designs are possible and they are
  not close:

  - *Live source*, like stocklists: a `vw_uprns_from_projects` resolving `ST_Within`
    against `projects.projects.geom` on every read. Consistent with the other three, and
    every project edit becomes an outstanding decision on the wayleave. Costs a fifth
    spatial branch in `vw_wayleave_pending`, doubling its expensive half, and inherits the
    `retained` edge case below.
  - *Copied boundary* (**preferred**): linking writes the project's geometry into
    `wayleave_polygons` as an ordinary polygon tagged with its origin. The wayleave then
    owns the shape. Project edits never touch it, which is the point — a wayleave is a legal
    position agreed at a moment, and it should not follow someone else's boundary silently
    (Dave, 2026-08-15). Re-sync is then just an ordinary polygon edit, so it runs through
    the diff and the approval flow with no new semantics, no new view, and no new spatial
    cost.

  **Drift has to distinguish who moved.** Once the copy is an ordinary polygon it can be
  edited deliberately — the wayleave may have been negotiated to a different line from the
  project. Storing only `source_project_id` cannot tell "the project moved" from "we changed
  our copy", and a re-sync would silently discard a deliberate legal edit. Storing the
  **source geometry as at copy time** gives three usable states from two comparisons:

  | stored-source vs project now | stored-source vs our polygon | Meaning |
  |---|---|---|
  | same | same | in step |
  | **differs** | same | the project moved — offer re-sync |
  | same | **differs** | we edited ours deliberately — leave alone |
  | differs | differs | both moved — needs a human |

  **Detecting it is cheap.** `projects.projects` carries `modified_datetime` and
  `modified_user`, so gate on the timestamp differing from the value at copy time and only
  then confirm with `ST_Equals` — and the badge can name who moved it. `projects_geom_idx`
  (gist) already exists, so either design resolves efficiently.

  **Deletion is not the blocker, despite appearances.** No module supports deleting a record
  yet. The copied design is insulated from it — a deleted project removes nothing, the badge
  simply gains a "source no longer exists" state. It is the *live source* variant that needs
  soft delete first, because a hard delete would mass-remove premises from a legal document.
  See the deletion item below.

  **Separable and cheap on its own: a drift badge for stocklists and titles.** Those are
  already live-sourced, so drift is exactly what `vw_wayleave_pending` computes per source
  today — and `wayleave_badge_counts.php` already runs that query. Returning its per-source
  rows rather than only the sums would let the Premises sidebar show "Stocklist1 — 12
  outstanding" against each attachment for no extra database work. It answers a question
  that exists now — *has this stocklist changed since we agreed it?* — and does not depend
  on the project work at all.

  **Two project↔wayleave relationships would share a screen.** The Projects tab already
  lists spatially *overlapping* projects; a linked project is a different claim. They need
  visibly distinguishing or the tab will be read as one thing.

  **The passive half applies to all sources, not just projects.** The diff is live so
  nothing goes stale, but nothing announces it either — the badge only updates when someone
  opens that wayleave, so a source edited today can sit unnoticed for months. That is the
  real "falls behind" risk and it is about people, not data. The `tasks` schema could carry
  it.

- [ ] **Record deletion must account for coverage links** *(2026-08-15)* — no module
  supports deleting a record yet, and `projects.projects` has no `is_deleted`. When that is
  built, deleting a project, stocklist or Land Registry title silently mass-removes premises
  from any wayleave sourcing from it: the source stops producing them, so every one appears
  as a pending removal on a legal document, with the cause unrecoverable. Worse for
  stocklists, where premises are already **hard-deleted** (`DELETE FROM
  stocklists.stocklist_premises` in `stocklist_address_search.php` ×2 and
  `opportunity_manage.php`), so removing a premise from a stocklist today leaves no trace of
  what went or who took it. Deletion needs at minimum to refuse, or warn, when the record is
  a coverage source for a live wayleave. Raised while scoping wayleave↔project linking.

- [ ] **An approver cannot see that a premise was decided before** *(2026-08-15)* — a
  direct UPRN that was rejected or removed can now be requested again (`wayleave_coverage_uprn_save.php`),
  which is right: requesting is not reversing, and the two belong to different people.
  But the approver sees the new request as an ordinary addition with nothing saying it has
  been refused once already, which is exactly the context a second decision wants. The
  history is there — `wayleave_approvals.uprn_removed_list` contains it — so the pending
  Detail modal could show "previously rejected on WL1-7 by X" per premise. Raised by Dave
  while separating requesters from approvers; not required for the module to work.

- [ ] **Wayleave boundaries cannot be drawn with cutouts** *(2026-08-14)* — a donut
  boundary is a single OGC Polygon with an interior ring, so the column type has never
  been the obstacle (and `MultiPolygon`, since `db/049`, holds rings just the same). The
  obstacle is the drawing tool: `ol.interaction.Draw({ type: 'Polygon' })` produces one
  exterior ring and offers no way to add an interior one. Supporting it needs a subtract
  step — draw the hole as a second shape and `ST_Difference` it server-side, or do the
  same client-side with JSTS — plus a way to edit or remove the hole afterwards. Raised by
  Dave while fixing 049; no requirement behind it yet.

- [ ] **`docs/README.md` plan index** *(2026-07-15)* — one-page list of every plan doc with
  its current Status / Phases Complete line, for a single-glance overview across plans.

## Completed

- [x] *(Dashboard)* **The headline count and its stage bar answered different questions** —
  **done 2026-08-04** (raised the same day, spotted by Dave while testing the dashboard
  config move). The card said "N live projects" and the strip beneath it showed a different
  population, so the two never added up and nothing on screen explained why.

  Two separate causes, fixed two different ways:
  - **Statusless records** counted toward the headline but belonged to no segment, so the
    bar silently summed to *less*. Fixed at source by `db/023_default_created_status.sql`
    — every module gains a "Created" status, the nulls are backfilled and the column is
    NOT NULL, so the case cannot recur.
  - **Completions inside the current month** appeared in the bar but not the headline, so
    the bar summed to *more*. Fixed by **widening the headline to match the bar** rather
    than labelling around it (Dave's call): the count is now current work *plus* anything
    finished this month, and the label reads **"current"** rather than "live". The strip
    is therefore the headline broken down — which is how a bar under a number is read
    whatever the labels say.

  The real repair is structural: `dashboardCurrentSql()` is now the single predicate both
  the count and the strip read, where each previously built the rule for itself. It is
  deliberately *not* a change to `dashboardActiveSql()`, which answers "is work still
  happening" and is what the flags need — a project completed last week must not start
  raising overdue flags again just because it is still on the card this month.

  **Known consequence:** the headline is now month-to-date, so it steps down on the 1st as
  the previous month's completions fall out. That is the intended reading of "current", but
  it is no longer a drift-free measure of how much live work exists.

  **What "unqualified" actually means** *(Dave's observation while testing, worth writing
  down because it is not obvious from the code)*: a card drops the "current" qualifier when
  `inactive_statuses` is empty — and `getDashboardConfig()` folds the complete status into
  that set, so it takes **both** an unset inactive list **and** an unset complete status to
  get there. Setting either one qualifies the count. Confirmed as intended behaviour.

  Tested by Dave 2026-08-04 across all three modules, including the no-inactive-set and
  no-completion-date cases, with the flags and the queue total confirmed unchanged.

- [x] *(Field Meta System)* **Admin "Statuses" management tab, with per-status colour** —
  **done 2026-08-03** (raised 2026-07-16, colour sub-item 2026-07-28). Delivered as
  `docs/2026-08-03-admin-status-management.md`: migration 022 (primary keys, display order,
  active flag, style token), a Statuses tab with drag-reorder, add/edit, style picker, usage
  counts and a removal guard, and the styles flowing down to the editor header badges on all
  three modules and to the dashboard stage bar.
  - **The contrast problem was dissolved, not solved.** The scoping note called contrast
    "the real work" — computing black-or-white per colour from WCAG luminance. Choosing a
    fixed, code-controlled registry (`statusStyleRegistry()`) instead of a free colour picker
    removed the need entirely: each of the fourteen tokens declares the ink that reads on it,
    decided once at definition time, so no consumer computes anything. The luminance maths
    was still done — once, to verify the declared inks — and reproduced the ratios already
    recorded in `custom.css` exactly.
  - **Not covered, deliberately:** wayleave (excluded throughout; its label-keyed pill CSS in
    `wayleave_list.css:20-25` is still there and joins on the module rewrite), and the
    project/account/stocklist **list views**, which still render status as plain text — that
    remainder is logged as its own open item above, since it needs a density decision rather
    than wiring.
  - **Map needed nothing.** The scoping note assumed map feature styling reads status;
    checked during planning — `map_v5.js` has no status-based styling, so the OpenLayers
    raw-value concern never arose.
  - **Ink readability confirmed in situ** by Dave 2026-08-03 across all fourteen tokens, so
    the declared-ink approach holds up in practice and not just on paper. **Still to check:**
    the shipped set under the `www/test.php` Vision switcher (separability under simulated
    protanopia/deuteranopia).

- [x] *(Field Meta System)* **`$moduleConfig` duplicated between `admin_load.php` and
  `admin_save.php`** — **done 2026-08-03** (raised 2026-08-02). Lifted into
  `global_functions.php` as `fieldMetaModuleConfig()`; both endpoints already required that
  file. The two copies were verified byte-identical before merging. Done as part of
  `docs/2026-08-03-admin-status-management.md` rather than duplicating a fourth key set.
  - **Related:** `statusModuleConfig()` derives from the existing `dashboardModuleMap()`
    instead of declaring its own status wiring — same reasoning, applied a second time.

- [x] *(Field Meta System)* **Presentation-only edits on static (system) fields in admin**
  — **done 2026-08-03** (raised 2026-07-17). Delivered as Phase 4 of
  `docs/2026-08-02-admin-field-meta-management.md`, which also closed the dropdown-option
  manager, the delete guard, the always-on data-type lock, the Input Type cascade and the
  section icon picker. Static rows now open in the field modal with label, section,
  category, order, spacer and required editable, plus their dropdown option list; form id,
  data type, input type and active stay locked, and deactivate/delete still refuse outright.
  - **Watch item:** the `reservedSystemColumns` guard had to be skipped for static rows — a
    static field's form id *is* an entity column by design, so the check rejected every
    system field until it was scoped to dynamic rows only.

- [x] *(Frontend / Assets)* **Adopt the Field-Safe colour scheme** — **repaint done
  2026-07-28** (raised 2026-07-27). Replaced the `#6d28d9` accent (Tailwind's `purple-700`,
  which read as a framework default) with `#5A3D8A`, and Bootstrap's stock status colours with
  a set chosen against simulated colour-vision deficiency — 20.7 ΔE worst-case separation
  vs 14.0. Tracking now lives entirely in `docs/2026-07-27-field-safe-colour-scheme.md`.
  - **Phases 1–3 of 4 are done and signed off**; **Phase 4 (consolidation) remains open
    there** — collapsing the triplicated `btn-geolynx-*` blocks, deleting the dead
    `.top-header` CSS, and removing the losing `main.css` navbar rule. The `www/test.php`
    deletion was dropped on 2026-07-28: the page is kept as the standing design-prototype
    surface, next for the status-colour work above.
  - **The colour-independence audit was removed from the plan on 2026-07-28** (by decision).
    It would have walked every status rendering to confirm it carries a label or icon rather
    than hue alone. That concern now rides with the Admin "Statuses" management item, since
    admin-set colours make the shipped 20.7 ΔE palette a default rather than a guarantee.
  - **The plan's Phase 3 mechanism was wrong and was corrected in place.** This is Bootstrap
    **5.2.3**: utilities (`.text-*`, `.bg-*`, `.border-*`) compose from `--bs-*-rgb` and do
    follow a variable override, but components (`.btn-*`, `.alert-*`, `.text-bg-*`, form
    focus, checkboxes, pagination, progress) compile *literal hexes* into their own
    `--bs-<component>-*` properties. Overriding `--bs-primary` alone would have missed ~170
    of the ~280 call sites — including all 56 `btn-primary` and all 60 `btn-success` — and
    looked like a near no-op. Fixed with per-component override blocks; still zero call-site
    edits, but ~200 lines rather than five.
  - **Vendored CSS is the blind spot** — anything shipping its own theme file carries literal
    colours no `--bs-*` override reaches. Tabulator's pagination needed explicit selectors at
    the vendor file's own specificity; jQuery UI and bootstrap-table are the other candidates.
  - **`custom.css` must remain the last stylesheet.** It was moved from the end of `<body>`
    into `<head>` to kill a flash of Bootstrap blue on load (a pre-existing bug the repaint
    made obvious). Many of its overrides win on document order at equal specificity, not on
    higher specificity, so a stylesheet added after it would silently undo them.
- [x] *(Frontend / Assets)* **Cache-busting for JS/CSS includes** — **done 2026-07-28**
  (raised 2026-07-17). Script/style tags were emitted with bare paths, so browsers served
  stale JS against updated HTML after a deploy (seen live: pre-phase-6 `admin_fields.js`
  clearing the removed `f-field_type` input → `TypeError` on the field modal, cleared only
  by a user knowing to hard-refresh).
  - **`asset_url()`** (`global_functions.php:748`) appends `?v=<filemtime>` to any
    docroot-relative asset path, resolving against `dirname(__DIR__)` rather than the CWD.
    CDN/protocol-relative URLs and files that can't be stat'd pass through untouched, so a
    bad path degrades to today's behaviour instead of a broken tag.
  - **Wired into** both `load_file()` css/js branches *and* all ten hardcoded local tags in
    `index.php` — the latter matters most, since that is where `main.js` and `custom.css`
    (the two most frequently edited assets) are emitted. `www/test.php` deliberately not
    covered (dev-only prototype page, doesn't include `global_functions.php`).
  - **Per-file mtime, not a single app-version token.** `git -C … pull` rewrites only the
    files whose content actually changed (verified empirically), so unchanged assets keep
    their URL and stay cached, one CSS tweak doesn't re-download the 439KB
    `project_edit_v2.js`, and there is no version to bump and forget — forgetting is what
    caused the original incident.
  - **No cache-header work needed:** no `.htaccess` under `www/`, and PHP's session cache
    limiter already sends `no-store` on the HTML, so the version string is never itself
    served stale. Not a disclosure concern either — Apache already sends `Last-Modified`
    and an mtime-derived `ETag` for these files.
  - **Residual caveats, all benign:** PHP's realpath cache can lag a deploy by up to
    `realpath_cache_ttl` (default 120s); a client server running `opcache.validate_timestamps=0`
    needs a reload before any PHP change takes effect; and an already-open tab keeps its old
    JS until reload (busting is pull, not push). Unblocks the Field-Safe colour scheme item
    (`docs/2026-07-27-field-safe-colour-scheme.md`), which was gated on this.
- [x] *(Database / Audit)* **Audit query: switch attribution to `history_datetime` /
  `history_user`** — **closed as not needed, 2026-07-28** (raised 2026-07-19, carried out of
  the audit-query rewrite). Reviewed against the code rather than the assumption, and the
  switch buys nothing on the front end:
  - **The premise was partly wrong.** Migration 018 added only `history_action` to the 15 EAV
    value-history tables — `history_datetime` / `history_user` exist on the three *entity*
    history tables only. The EAV branches were never switchable, and never needed to be: the
    triggers already write `record_datetime = now()` and `record_user = app.user_id GUC ??
    row.record_user`, which *is* the attribution the item was asking for.
  - **Timestamps are identical, not merely close.** `now()` is transaction start time in
    Postgres, so the BEFORE UPDATE trigger's `modified_datetime` and the AFTER trigger's
    `history_datetime` are written from the same value in the same transaction. On INSERT,
    `modified_datetime` carries `DEFAULT now()` on all three entity tables. The only divergent
    case is DELETE — and there is no `delete from` against the three entity tables anywhere in
    `www/`, nor could such a row render (you cannot open a deleted record's editor).
  - **`modified_user` is already the real actor.** A sweep of every write path to the three
    entity tables found all of them setting it explicitly; `history_user` also falls back to
    `modified_user` when the GUC is absent, so the two columns hold the same integer on
    virtually every row. The single exception — `image_upload.php` — is now its own item under
    Database / Audit.
  - **What remains is defensive only:** `history_user` would catch a *future* write path that
    forgot `modified_user`. That is an argument for the column existing (it does), not for
    changing the query. Reopen only if the audit log grows to cover columns written by paths
    that skip `modified_user`.
- [x] *(Autocomplete / UX)* **Redesign the parent/child hierarchy display in the editors**
  *(2026-07-17; built & tested 2026-07-26)* — the two side-by-side warning/info alerts are
  gone. Five designs were prototyped in `www/test.php` (lineage rail, breadcrumb strip,
  header badge + popover, two-column card, dense table), each shown inside mock editor
  chrome against five data scenarios. The rail won: `renderHierarchyAlerts()` is now
  `renderHierarchyPanel()` in `main.js`, styles in `custom.css` under the `.hx-*`
  namespace. One nested tree — root at the top, the record being edited highlighted on the
  spine, descendants branching beneath it — so the picture on screen is the shape of the
  data, and direction reads from position plus a glyph rather than from alert colour.
  Siblings past twelve in a branch collapse behind "Show N more"; the panel has a Hide
  toggle. The header badge variant was rejected on the grounds that a hierarchy is the one
  thing that should never be a click away: two controls doing one job, and the panel
  already collapses.
  **Also fixed along the way:** record names are HTML-escaped (the alerts interpolated
  them raw, so a crafted project name executed); distances are formatted (`1.4 km`, not
  `~1420m`) and carry a tooltip saying what they measure, since `vw_parent_*_links` gives
  each row's distance to *its own* parent, not to the record being edited.
  **Backend:** the child queries in `project_load.php` / `account_load.php` /
  `stocklist_load.php` now return `parent_<entity>_id AS parent_id` so the tree nests by
  real parentage instead of by level — the recursive CTEs already carried the column, only
  the final `SELECT` dropped it, so no view change and no migration. The renderer falls
  back to a flat list when that column is absent, so JS and endpoints deploy independently.
  **Latent bug found in testing:** `stocklist_load.php` had `//$sth5->execute();` — the
  child-stocklist query was prepared and bound but never run, so `fetchAll()` returned
  false and `data_child_stocklists` was never set. A stocklist's children have therefore
  never displayed, under the new panel or the old alerts; only the parent direction worked.
  Uncommented. `project_load.php` and `account_load.php` were unaffected, and a sweep of
  `www/fn/` found no other commented-out `execute()` call.
- [x] *(Security)* **`map_get_v2.php` read authorization — plus an unreported injection and
  the whole of `map_get.php`** *(2026-07-19; built & tested 2026-07-26)* — the original note
  called this "an authorization gap, not an injection one". That was wrong: `fields` was
  concatenated raw into the SELECT list, so a subselect could read any table regardless of
  `geotable`, and the proposed `map_layers` whitelist would not have closed it — a whitelist
  constrains `FROM`, not the select list. Removed the parameter outright rather than
  validating it (no caller passed it; column restriction belongs in a view, which can also be
  registered as a layer and permission-checked, as a string cannot). **`map_get.php` (v1)
  deleted** — no `checkDatabaseTable`, no column checks, `parameters` concatenated straight
  into `WHERE` and run through `PDO::query()`; its five callers all sat inside
  `mapProjectDetail()` in `project_edit_v2.js`, whose only reference was a commented-out call
  in `main.js:96`, so nothing reached it. That dead function (418 lines, the pre-OpenLayers
  Leaflet project map) went too. **Authorization**, built as one gate placed after
  `checkDatabaseTable()` and before any data work: a `$schemaModuleMap` resolves the owning
  module from the relation's *schema* (never from a caller-supplied parameter), then the
  existing `canAccessModule()` enforces module-enabled + read. Unmapped schemas (`users`,
  `landregistry`, `openreach`, `orion`, `public`, …) and unqualified names are refused —
  the first real constraint on `geotable` beyond existence. Deriving from schema rather than
  `map_layers` is what avoided blocking on the wayleave layer migration. **Record-level**:
  the `stocklist_id` and `agreement_id` branches skipped company scoping entirely (the
  agreement one deliberately — "access is session-controlled", though nothing here controlled
  it), so any logged-in user could read another company's agreement polygons and UPRNs; both
  now mirror the project branch via a new `userCheckCompanyItemAssigned()` built on the
  existing `getItemCompanyId()` map. Also fixed: scoping flags were set *before* the
  column-existence check (so `where={"agreement_id":1}` skipped scoping), that check's `exit`
  was commented out (so an unknown column silently dropped the filter and returned the whole
  relation), and `$companyID` was concatenated (broken query for a user with no company row).
  All denials now emit an empty GeoJSON FeatureCollection rather than a bare `exit()` — parses
  cleanly, and doesn't confirm whether the record exists. Verified by Dave both ways: the
  endpoint served UPRNs as main company and returned nothing after switching company, hit
  directly in a browser tab — which is the point, since that path bypasses `index.php`'s
  route gate entirely. **Scope decisions:** record-level `getItemPermission()` overrides
  deliberately *not* extended to map reads (wired for project/account/stocklist but no
  real-world use case); relation-level whitelist granularity deferred to the wayleave layer
  migration (see "Align wayleave to the other modules"). **Correction:** interim notes listed
  `companalysis.js` as a live caller — it is never loaded (no route, no template include, no
  script tag), so `wayleave_edit_v2.js` is the only one. `companalysis.js`,
  `html/companalysis.php` and `html/nav_companalysis.php` are dead and not yet in
  `docs/unusedfiles.md`.
- [x] *(Security)* **SQL injection in `map_layer_manager.php` distinct-values mode**
  *(2026-07-19; closed by deletion & tested 2026-07-25)* — the reported hole was real as
  written (`getLayerColumnDistinctValues()` concatenated `$_GET['field']` into the query
  four times, unvalidated) but **never reachable**: both injectable modes,
  `get-unique-values` and `get-layer-summary`, called `getSingleLayer($dbh, …)` and `$dbh`
  is undefined in that file — `db.php` provides `$pdo`, and none of the four included
  files assigns a global `$dbh`. `null->prepare()` is an `Error`, which the function's
  `catch (PDOException $e)` doesn't catch, so every request fatal-errored before reaching
  the injection. Confirmed unreachable in every commit back to `35db55c`, so no exposure
  to disclose. Both handlers were also reading pre-`6937459` column keys (`LAYER_TITLE`,
  `LAYER_DATA_SCHEMA`, `LAYER_DATA_TABLE`, `LAYER_WHERE_CLAUSE`) that don't exist on
  `public.map_layers`, and neither had any JS caller — the `map_v5.js` filter UI that
  looks like the summary front end builds client-side CQL filters for GeoServer WMS and
  never posts to PHP. Deleted rather than hardened: both `case` blocks,
  `getLayerColumnDistinctValues()`, `getLayerSummary()`, plus the never-called
  `getLayers2()` / `getLayerMeta()` — 261 lines, no insertions. Rationale for deleting a
  non-exploitable bug: swapping `$dbh`→`$pdo` is exactly what someone clearing undefined-
  variable warnings would do, and that one change armed it. Surviving modes (`get-layers`,
  `get-layers-for-page`, `get-page-configs`, `save-page-config`, `delete-page-config`,
  `get-columns`) all have live callers; verified by Dave — maps and admin section load fine.
- [x] *(Database / Audit)* **Audit-log queries — metadata-driven rewrite (projects /
  accounts / stocklists)** *(2026-07-16; promoted to
  `docs/2026-07-23-audit-log-metadata-driven-rewrite.md` 2026-07-23; built & tested
  2026-07-24)* — replaced ~450 lines of hand-copied per-field CTEs per module (×3) plus the
  duplicated Tabulator formatters (×3 editors) with one generator, `buildAuditLogQuery()` in
  `global_functions.php`. Static-field labels resolve from meta (`field_options_source` →
  status; `field_autocomplete_type` → username/parent/account/company; else plain), reusing
  the `resolveAutocompleteLabel()` / `$optionsSources` maps; dynamic (EAV) branches loop the
  five value types; access-log + journal (incl. file upload/download logs) preserved. Output
  collapsed to plain-text `previous_value` / `new_value` so the editors render Tabulator
  fields natively (formatters + dead Bootstrap-Table blocks deleted). Absorbed Action 2's
  blank→blank filter and auto-fixed the account/stocklist copy-paste label bugs ("Project
  Name" / "Project Status"). Validated by side-by-side front-end comparison across all three
  modules. The one follow-up it left open (attribution → `history_*`) was reviewed and closed
  as not needed on 2026-07-28 — see its own entry above; opportunity/wayleave deferred to
  their own alignment.
- [x] *(Database / Audit)* **Action 2 — filter no-op blank→blank dynamic-field rows from the
  audit panel** *(2026-07-20; stopgap 2026-07-23; superseded by the rewrite & tested
  2026-07-24)* — the interim single outer `WHERE` (on `select * from a` in the three
  `*_load.php`) was folded into `buildAuditLogQuery()` as the generated query's built-in
  blank→blank filter; the stopgap clause was deleted along with the old CTEs. Hides both new
  and pre-existing phantom rows; genuine "clear to blank" edits still show.
- [x] *(Database / Audit)* **Action 1 — save side: stop writing spurious `NULL` rows for
  blank dynamic fields** *(2026-07-20; fixed & tested 2026-07-23)* — save-side half of the
  blank→blank pair (Action 2, the load-side display filter, is still open under Database /
  Audit). `project_save.php` nulled `$sanitized_value` from `""` to `null` *before* calling
  `checkProjectEditCurrentValue()`, defeating that function's "no existing row + blank input
  = no change" guard (it tests `$value === ""`, which a nulled value never matches) — so the
  first save of any project wrote a spurious `NULL` row into every blank dynamic field's
  `*_field_values_*` table (and, via the entity-history triggers, its `*_history` table).
  Fixed by reordering: the change check now runs against the raw `""` value and the null
  conversion moved *inside* the `if ($checkValueHasChanged)` block. **One file** — the
  item's "same shape needed in all three save files" premise was wrong: `account_save.php`
  and `stocklist_save.php` already had the correct ordering; only `project_save.php` was
  buggy, and it now matches them. Static fields out of scope (real column UPDATE, not EAV
  rows). Note: prevents *future* phantom rows only — pre-existing ones stay hidden by
  Action 2 at display, or a separate one-off cleanup migration (unscoped) if they ever need
  physically deleting.
- [x] *(Database / Audit)* **Entity history capture via DB triggers** *(2026-07-16;
  promoted to docs/2026-07-19-entity-history-triggers.md 2026-07-19, built
  2026-07-20, tested & verified 2026-07-22)* — replaced the PHP-side history
  inserts in `project_save.php` / `account_save.php` / `stocklist_save.php` with
  per-table `AFTER INSERT OR UPDATE OR DELETE` triggers on the three entity
  tables (`db/017_entity_history_triggers.sql`) and all 15 EAV value tables —
  projects/accounts/stocklists × text/int/numeric/date/boolean
  (`db/018_eav_history_triggers.sql`). Now captures every write path — editor
  saves, map boundary/geometry saves, cover-image upload, create endpoints,
  manual SQL — not just the save endpoints; atomic with the triggering
  statement; INSERT/DELETE now auditable, not just UPDATE. Actor attribution via
  `set_config('app.user_id', ...)` in `db.php`, falling back to
  `modified_user`/`record_user` for endpoints on private PDO connections (see
  the still-open item above). One checklist item not exercised: brand-new
  project/stocklist creation producing an `'INSERT'` row (not separately
  re-tested). Wayleave intentionally deferred to its own future phase. See the
  plan doc for full design decisions.
- [x] *(Database / Audit)* **Interim: explicit column lists in the two `SELECT *`
  history inserts** *(2026-07-16; superseded 2026-07-22)* — moot: the trigger
  item above removed the PHP-side `insert into ..._history select * from ...`
  statements entirely rather than patching them.
- [x] *(Frontend / Assets)* **Map layer delete permission lost in the JSON→DB
  layer-config move** *(2026-07-19; fixed & tested 2026-07-19)* — regression: the old
  hardcoded layer JSON's per-layer `layerAllowDelete` flag was never carried into
  `public.map_layer_page_config` by the archived 001–004 migrations, so
  `project_edit_v2.js`'s delete gate (`layer.get('allowDelete')`) was always
  undefined and delete was silently disabled in the project editor. Fixed by
  `db/016_map_layer_allow_delete.sql` (adds `layer_allow_delete boolean DEFAULT
  false NOT NULL`, seeds `true` for the six projectedit layers that had it pre-move
  per git history `6937459^`: `PlanEquipment`, `PlanStructures`, `PlanCables`,
  `PlanDuct`, `PlanSubDuct`, `projectPIABlockages`; loud failure if names/config
  rows are missing) + `map_layer_manager.php` (page join, per-layer config list,
  POST parse, upsert) + `map_layer_utils.js` (`layerAllowDelete` mapping) +
  `admin_map_layers.js` (Allow Delete checkbox in the page-config rows, so new
  layers don't need direct DB access). `project_edit_v2.js` unchanged. Scope
  decision: delete only in project + wayleave; wayleave was never affected — its
  polygon delete is hardcoded in `wayleave_edit_v2.js`, outside the DB layer
  config, and the flag will apply there if/when its entity layers move onto the
  config (future unified map module).

- [x] *(Performance)* **`project_auto_route.php` — break up the one-shot routing query**
  *(2026-07-17; done 2026-07-19)* — rewritten as the decided two-step: query 1 segments
  the drawn line and snaps all endpoints in one pass (`<->` KNN ordering inside the
  existing tolerance bbox filter); query 2 makes a single `pgr_dijkstra`
  **combinations-signature** call — edge graph built once over the whole drawn line
  expanded 1000m, so the PIA-blockage `st_intersects` join runs once instead of per
  segment pair — then the unchanged merge (routed edges + unrouted-segment fallback →
  `ST_Union`/`ST_LineMerge` → simplify → one editable geometry). PHP ferries the
  `(route_id, startnode, endnode, segment_wkt)` pair list between the queries as
  jsonb. Also while in there: debug `$data['query']` echo of the full SQL dropped;
  the always-passing `!$x >= 0` input checks replaced with real `is_numeric`
  validation, and the civils/PIA multipliers (previously `str_replace`d raw into the
  SQL) are now validated + cast to float before interpolation. Note: single-segment
  draws are roughly a wash (one graph build either way) — the win scales with
  segment count.

- [x] *(Security)* **`fn/get_update_form.php` has no login check** *(2026-07-16; fixed
  2026-07-17)* — unlike the `*_load.php` endpoints (`loginCheck('func')` +
  `isModuleEnabled`), the field-metadata endpoint answered unauthenticated requests. Now
  gated like its siblings: `loginCheck('func')` plus `isModuleEnabled` via a
  singular-type → module-name map (`project` → `projects`, ..., `wayleave` →
  `wayleave`). Callers (the five editor JS files) are all behind the page login check,
  so no behaviour change for real users.
- [x] *(Autocomplete / UX)* **Parent/child hierarchy alert copy-pasted from projects**
  *(2026-07-17; fixed 2026-07-17)* — the child-hierarchy alert in the stocklist
  (`#child-stocklists`) and account editors was the projects markup verbatim, including
  the "~Xm" distance text that only makes sense for projects (the account/stocklist
  `vw_parent_*_links` views return `NULL::numeric` distance, so it rendered "~nullm").
  Replaced all four copy-pasted blocks (project/account/stocklist/opportunity editors)
  with a shared `renderHierarchyAlerts()` in `main.js`: per-module copy, distance shown
  only for projects (and only when non-null), parent-then-children order everywhere,
  BS5 `col-12 col-lg-6` side-by-side layout (was broken BS3 `col-xs` on projects,
  unwrapped on the others), and idempotent rendering (container emptied per load).
- [x] *(Autocomplete / UX)* **`companyname` autocomplete dead for non-company-1 users**
  *(2026-07-15; fixed 2026-07-17)* — `fn/autocomplete.php` exited for `companyname`
  lookups when the user's company ≠ 1, so non-super users saw a company field that
  displays its value but can't search. Decided behaviour: company-1 users search all
  companies; everyone else sees only company 1 and their own company, so a contractor
  can hand a record back to the managing company but never assign it to another
  contractor. Enforced in the lookup (`autocomplete.php`) and server-side in
  `project/account/stocklist_save.php`, which reject any other `company_id` from
  non-company-1 users (closes the direct-POST reassignment gap; wayleave saves don't
  whitelist `company_id`).
- [x] *(Autocomplete / UX)* **Self-parent / parent cycles hang the load APIs — SERIOUS**
  *(2026-07-15, severity confirmed 2026-07-17; fixed 2026-07-17)* — setting a record as
  its own parent saves successfully and the editor then hangs on load: the
  parent-hierarchy recursive CTEs in `project_load.php` / `account_load.php` /
  `stocklist_load.php` never terminate on a cycle. Fixed in all three layers: (1)
  save-side guard in each `*_save.php` (incl. `wayleave_save.php`) rejects
  `parent_*_id = <own id>`, and the editor save handlers now surface `success:false`
  messages instead of always reporting "Saved!"; (2) path-array cycle detection in the
  recursive CTEs (also terminates multi-record cycles A→B→A); (3) `exclude_id` param in
  `fn/autocomplete.php` — parent-field lookups (derived from the `parent_*` target id
  in `main.js`) exclude the record and its descendant subtree.
