# Planner Module Update

- **Date:** 2026-09-09
- **Status:** Release 2 in progress — **Phase 4 is COMPLETE**: chunks 1, 2, 2b, 2c, 3, 4, 4b, 5, 6, 7 and 8 all built and tested on dev, migrations 082–086 and 088–091 applied. **Phase 5 is COMPLETE**: the containment registry, derive, per-feature saving, route saving, the Contents tab, setting a parent, recording which way of a duct a run occupies and the explode toggle are all built and tested on dev, migrations 092–098 applied. **Phase 6 is COMPLETE**: ports, connections, cable end kinds, the connection canvas and the splicing schedule are all built and tested on dev, migrations 099–106 applied. **Phase 7's schematic half is COMPLETE**: nodes, edges, containment and styling all built and tested on dev, migrations 107–110 applied — see `docs/2026-09-28-planner-topology-rebuild.md`. The rest of Phase 7 (cutover and cleanup) and the manual GeoServer re-point remain.
  Release 1 (Phases 0–3) is complete and tested on dev, migrations 066–081 applied, and
  committed as `6a270d0`. The planner runs on `planner.elements` with a configurable
  registry, a shared style library, nine type-scoped dynamic fields, and history on all
  five element kinds. **The GeoServer re-point stays outstanding until Release 2 is
  delivered** (Dave, 2026-09-20) — it is manual, and the three published layers have been
  serving empty since migration 074 renamed their sources. Decision 24 makes it simpler
  when it happens: all three read one view with a CQL filter.
- **Status Date:** 2026-09-29
- **Phases:** 8
- **Phases Complete:** 4 of 4 in Release 1 (Phases 0 ✅, 1 ✅, 2 ✅, 3 ✅ — GeoServer
  re-point outstanding, manual, deferred to after Release 2). Release 2: Phase 4 chunks 1 ✅, 2 ✅,
  2b ✅ (built and reverted), 2c ✅, 3 ✅, 4 ✅, 4b ✅, 5 ✅, 6 ✅, 7 ✅, 8 ✅ — Phase 4 complete.
  Phase 5 steps: 1 ✅ type dimensions (091), 2 ✅ A/B end markers and distance-ordered end
  pickers, 3 ✅ containment registry (092), 4 ✅ derive, read only, 5a ✅ saving becomes per
  feature, 5b ✅ routes saved with the feature (093–094), 6a ✅ Contents tab and capacity
  (095–097), 6b ✅ setting a parent (098), 6c ✅ which way of a duct a run occupies,
  7 ✅ the explode toggle — Phase 5 complete.
  Phase 6 steps: 6a ✅ ports — element_ports generated from the type template, editable per
  element, with the Ports tab (migrations 099–102). 6b ✅ connections — port-to-port at a
  structure, per-port targets in the Ports tab (migration 103). 6b2 ✅ cable end kinds (migration 105): an A/B end may be equipment or a
  structure, and which one decides the candidate set. Equipment is a **closed** set (the
  cable connects to that unit and nothing else, because a sealed PBO or closure has nothing
  else inside it to reach); a structure is an **open** set (anything housed in or terminated
  at it). Configured per type by `element_types.can_be_cable_end`, because both kinds hold
  both cases — a Splitter Box is a structure and a housing, a CMJ closure is equipment and
  also a housing, a bare 1:8 splitter is equipment that must sit inside one. The closed case
  was previously **impossible to express**: `fn_element_present_at` is false for an element
  against itself, so a cable joined to its own end-equipment's port was refused by 103's
  presence check.

  6c ✅ **the connection canvas**, built and tested in five chunks 2026-09-27/28: the modal
  and both entry points, the canvas itself, saving, and the Ports tab's connections going
  read-only. `planner_canvas_load.php` and `js/planner_canvas.js` are new;
  `planner_connection_targets.php` was deleted and `pair` / `connect` removed from
  `planner_connections_save.php`, all three having lost their only caller. Two guards landed
  with it: a connected port cannot be terminated or removed, and a terminated port cannot be
  connected — between them those close a state that had no way out.

  6d ✅ **the splicing schedule** (migration 106), tested 2026-09-28. The Fibre Allocation tab
  becomes a project-wide **Splicing Schedule** — `planner.vw_splice_schedule` gives one row per
  port per location it is present at, so a fibre joined at both ends of its cable appears twice,
  which is two jobs at two chambers. Spares fall out of the same query as rows with the far side
  blank, so "what to splice" and "what is left" are one list filtered two ways. Grouped by
  location on screen and flat in the file, with Location on every row — the download is
  Tabulator's own `download('xlsx')` over the SheetJS that `index.php` already loads for the
  project and premise lists, so it exports whatever filter is applied and needed no PHP writer.
  `project_fibre_allocation.php` is deleted with the free-text grid it served.

  **Still to do, deferred from 6c:** the one-click fix on the canvas's unlinked-cable panel,
  which writes an A/B end on `planner.elements` rather than a connection.
- **Notes:** Rebuilds the project-editor network planner as a first-class **Planner** module in a
  new `planner` schema, replacing the planner tables in `prospector`. Design settled in
  conversation 2026-09-02 → 09, reviewed against the code and completed 2026-09-20; the research
  behind it is `docs/2026-09-02-asset-management-research.md`. Milestones/snapshots and the assets
  module are deliberately out of scope and get their own plans — this build lays the seams for
  both. **Delivered as two releases** (decision 11): Release 1 is Phases 0–3, after which the
  planner works exactly as it does today but on `planner.elements` with a configurable registry;
  Release 2 is Phases 4–7. Each release gets its own hard cutover and its own test pass. Old data
  is renamed table-by-table as it migrates and the `prospector` schema is left standing
  (decision 16).

## Plan Phases

**Release 1 — the planner on its new foundation, behaviour unchanged**

0. ✅ Schema foundation — `planner` schema, registries, elements, relationships, views
1. ✅ Data migration from `prospector`
2. ✅ Module registration, permissions and admin
3. ✅ Map layers, views and styles — except the GeoServer re-point, which is manual

**Release 2 — the new capabilities**

4. ✅ Configuration made real — one view, kinds as mechanics, the meta-driven form, auto-provisioning
5. ✅ Relationships, routing and capacity
6. Connections, ports and structured fibre allocation
7. Schematic rebuild, cutover and cleanup

## Problem

The network planner works and is proven with the first client, but it was built as a set of
loosely related spatial tables in `prospector` rather than as a module. Six consequences:

- **Nothing is configurable.** Element types (`point_type`, `cable_type`, `duct_type`, …) and
  statuses are hardcoded as dropdown options inside `public.map_layers.editable_fields` JSON. A
  client planning EV chargers or a water network cannot set up their own equipment without a
  developer editing JSON in a database column.
- **There is no relationship model.** A cable knows its `a_end` and `b_end`, and points and
  structures have a parent id, but nothing records what a cable or subduct *runs through*. So the
  system cannot answer how full a duct is, which PIA sections a subduct uses, or what would be
  affected by a blockage.
- **Connections are free text.** `network_cables.fibre_allocation` and
  `network_points.point_fibre_allocation_details` are text columns typed by hand. There is no port
  model, so no splice schedule can be generated and the schematic can only show element-to-element.
- **`prospector` is the wrong home.** The schema name is a leftover from the opportunity split
  (migrations 024–030), and the module has no `app_modules` row, no permission key and no admin
  surface. It is not only cosmetic: because `map_get_v2.php` still maps that schema to the
  **opportunities** module, the planner layers are today served on the opportunities permission —
  so an opportunities-only user can read the network and a projects user without opportunities
  cannot.
- **Four of the five kinds have no audit trail.** Only `network_cables` has a history table and
  triggers. A point, structure, duct or subduct edit or delete is unrecoverable and unattributable,
  in a module whose output is a build instruction.
- **It blocks the assets module.** An asset register is the as-built continuation of the plan.
  Without a relationship model, a type registry and an ownership concept in the planner, promotion
  to assets would either re-key everything by hand or invent a second, divergent model.

### What this build is not

- Not milestones/snapshots — separate plan, but the ownership and relationship model here is what
  makes them possible.
- Not the assets module — separate plan. The seams are listed under *Seams left for assets*.
- Not mixed PIA-and-planned routing. Auto-route continues to route over the Openreach PIA network
  only. Combining both into one pgRouting graph needs node matching, a snapping tolerance and a
  topology rebuild on every edit; at 1% of a typical run it would not repay the build.

### Settled design decisions

Recorded here because they are the reasoning behind the schema, and re-deriving them later is
expensive.

| # | Decision | Reasoning |
|---|---|---|
| 1 | **One `elements` table**, not five by kind or three by geometry | `a_end`/`b_end`, routing container and containment all reference *elements*, and the relationship graph aligns with neither geometry nor kind. Any split makes all three polymorphic, and a polymorphic reference cannot carry a foreign key. Geometry problems are solvable with views, a trigger and partial indexes; missing foreign keys are not solvable at all, and would be inherited by the assets module |
| 2 | **Per-kind/geometry views are the only read surface** | Solves GeoServer (a view with a cast geometry column reports its type and SRID properly), and contains the "forgot `WHERE kind`" risk to one save endpoint |
| 3 | **Three-level taxonomy: kind → category → type** | Kind is code and fixes relationship mechanics. Category carries geometry kind and the map layer it renders under. Type carries the spec. Category is what lets duct and subduct share a table while staying distinct in the UI |
| 4 | **Subduct collapses into duct** | A subduct is a duct with a container. Gives three-level nesting (microduct in subduct in duct) for free, and means routing is implemented once |
| 5 | **Load-bearing columns only; everything else dynamic** | `pianoi_ref`, `customer_*_id`, address and contact details become type-scoped dynamic fields, matching every other module |
| 6 | **PIA is never a planner element** | Third-party infrastructure stays a reference layer. Our subduct records its path through their network via external routing links. Guarantees no PIA duct can ever be promoted to an owned asset |
| 7 | **Ownership is a first-class column** | Only owned elements promote to assets. Enforced in the model rather than remembered at promotion time |
| 8 | **Connections store one row per port pair** | Ranges are a UI convenience only. Real networks allocate non-contiguously (a 4F into splitter outputs 1, 2, 5, 7), which range-with-exceptions storage cannot express cleanly |
| 9 | **`a_end`/`b_end` stay, alongside connections** | They record where a cable end physically *terminates* (equipment **or** structure); connections record what the fibres *join to*. A cable pre-pulled into a cabinet has the first and not the second |
| 10 | **Types are instance-global** | Each client is a self-contained server; `company_id` distinguishes subcontractors within a client, not tenants |
| 11 | **Two releases: Phases 0–3, then 4–7** | Phase 6 is a new subsystem and Phase 4 replaces the save path. Release 1 moves the store and makes types configurable while the planner behaves exactly as it does today, so the registry win lands early and the risky parts cut over separately |
| 12 | **`admin_planner` is a real key, wired in `routes.php` and `$modePermissions`** | Configuring the network catalogue and managing form fields are different jobs. Wired rather than declared, so it does not become the third grantable-but-inert admin key alongside `admin_map_layers` and `admin_dist_analysis` |
| 13 | **`routing_weight` is a per-type default; the per-run avoidance controls still override it** | The multipliers are not hardcoded today — `project_auto_route.php` reads `routeCivilsAvoidance` / `routePIABlockageAvoidance` from the request, so they are values the planner sets per run. The registry supplies a sane starting value per type; nothing the planner can do today is taken away |
| 14 | **The schematic is rebuilt over `planner.elements`, not relocated** | Its two views hardcode `point_type = 'L0'`, derive Cytoscape classes by string-mangling `point_type`, and inner-join both cable ends to `network_points`. None of those survive a registry. `sld_x`/`sld_y` become real columns on `planner.elements` — they are written on every node drag, so an EAV round trip is the wrong home |
| 15 | **Soft delete plus history on every element kind, no archive UI** | `is_deleted` and the journal, plus `planner.elements_history` with the standard triggers (`docs/2026-07-19-entity-history-triggers.md`). Today only `network_cables` has history; points, structures, duct and subduct have none, so this is the build that gives all five an audit trail. No archive list, no restore screen and no `planner_approve` pseudo-module — those follow `docs/module-delete-restore-pattern.md` later if wanted |
| 16 | **Old data is renamed table-by-table as it migrates; the `prospector` schema stays** | Each source becomes `prospector.network_points_migrated_<date>` and so on. Renaming in place is what makes a reader the audit missed **fail loudly** instead of silently reading stale data — a schema rename moves every reader at once and hides which ones were still live. It also leaves the `*_migrated_20260804` opportunity tables and `prospector.estimates` alone, which this build never examined. A follow-up job after the module is complete reviews the whole schema and backs up/drops it |
| 17 | **The seed catalogue's source of record is the live `editable_fields` export** | `reference-data/project_map_elements_types_seed.json`, exported from `public.map_layers` for layer_ids 36–41. Not the archive migration (which can drift from live) and not an invented range. Fibre/telecoms only — an electrical or water set is guesswork until there is a client to shape it |
| 18 | **`geom_user` becomes `planner.elements.geom`; the original `geom` is dropped** | Cables, duct and subduct each carry two geometry columns today. `geom_user` is the live one — the only column `map_feature_save.php` writes, the one all three map layers serve, and the one the schematic measures. Nothing reads or writes the bare `geom`. One geometry column, named for what it is (Dave, 2026-09-20). **Confirmed by audit report 10**: `geom` is 100% NULL on all three tables and `geom_user` 100% populated, so nothing is discarded at all |
| 19 | **Phase 1 step 6 and the `editable_fields` emptying both move to Phase 4** (Dave, 2026-09-20) | Taken literally, Release 1 left the planner uneditable. Nothing in Release 1 renders from the field meta system — the attribute form is built from `editable_fields` (`map_layer_utils.js:1175` → `project_edit_v2.js:1262`) and populated from the feature properties the map layer serves (`5584`). EAV values are in neither, and the map views cannot join them in without breaking the filterability rule. Moving the columns to EAV in Release 1 would therefore empty the equipment form from six fields to two, a release before the renderer that reads them arrives. The eight columns stay on `planner.elements` and Phase 4 moves them, seeds the field meta and empties the JSON in one change |
| 20 | **Release 1 carries the endpoint re-point** (Dave, 2026-09-20) | Phase 1 step 9's rename kills `map_feature_save.php` and five other endpoints on the same deploy, and the plan's own risk section says the Phase 4 endpoint table "has to be right before Phase 1 runs, not after" — but the release split put that table in Release 2. So Release 1 adds `planner_load.php` / `planner_save.php` and re-points `map_boundary_update.php`, `map_get_connection_points.php`, `project_load.php:433`, `network_topo_save.php` and `opportunity_process.php`. The rename then does what decision 16 wants: a reader the inventory missed fails loudly |
| 21 | **The default status is an admin-settable flag, not a column default** (Dave, 2026-09-20) | Everywhere else `statusDefaultId()` reads the entity column's default out of the catalog, fixing the default at migration time and making that one status permanently undeletable. Planner is the module where a client redefines the whole list — an instance planning EV chargers has no use for *Surveyed* — so the one thing that could not be configured is the one that most needs to be. `element_statuses.is_default`, one row true, protection following the flag |
| 22 | **`length_m` is a real column, trigger-maintained across all three linear kinds** | Not in the original plan, because grepping `www/` says nothing writes `cable_length` — the logic is `prospector.calculate_cable_length()`, a database trigger, invisible to the application (Dave, 2026-09-20). Dropping the column would have silently frozen `project_load.php:431`'s total-cable figure. `network_duct` and `network_subduct` have the same column and **no** such trigger, so one trigger on one table now gives the two kinds that never had it the guarantee cables always had |
| 23 | **Release 1 made the registry editable and gave it nothing to drive** (Dave, 2026-09-20) | A category created in `?do=admin_planner` produced a row and nothing else: it had no view, no `map_layers` row, no attribute-form container, no entry in `PLANNER_SAVE_LAYERS` or `planner_save.php`'s `$layerCategories`. Four code changes per category. A type created in admin was invisible too, because the `type_key` dropdown was hardcoded JSON in `editable_fields`. **This is what Phase 4 is actually for**, and it is the asset module's precondition: research item 2 is the piece that "makes the module work for water and street lighting as well as fibre", and a taxonomy needing a developer per category cannot carry that |
| 24 | **One view — `planner.vw_elements` — and a category is a filter on it** (Dave, 2026-09-20) | Decision 2 gave one reason for per-kind views: a cast geometry column reports its type and SRID properly, which GeoServer needs. Both halves are answered. `map_get_v2.php` **never reads `geometry_columns`** — it does `st_asgeojson(st_transform(geom, :srid))` off the relation and returns GeoJSON, so mixed geometry is a non-issue for all six editor layers. And GeoServer filters with CQL against the same view, on an instance with one SRID throughout. The geometry is still cast, to `geometry(Geometry, 27700)` — open type, declared SRID — so `geometry_columns` still reports 27700. `vw_elements_duct` and `vw_elements_subduct` differed by one predicate; that is now a layer filter. **No DB change per category, ever** |
| 25 | **The layer filter is `filter=`, not `where=`, and that is not a style choice** (found 2026-09-20) | Every editor builds its source as `layer.url + '&where={"project_id":"N"}'` (`project_edit_v2.js:1150,1176,1225`, and the same in `map_v5.js`, `opportunity_edit.js`, `stocklist_edit.js`, `account_edit_v2.js`). A `where` stored in a layer's url would be the **first of two occurrences of that query key, and PHP keeps only the last** — so the stored category filter would be silently discarded and every planner layer would serve every category of its kind. A separate key cannot collide. It validates identically (`checkDatabaseColumn` + bound placeholders) and **deliberately does not set `$has_project_id` and its siblings**: those decide record-level company scoping, and a key that can appear in a url must never reach that decision |
| 26 | **The five partial GiST indexes become one** (found 2026-09-20) | `db/067` built one per kind and its comment says why the predicates matched the views literally: "Postgres proves index applicability syntactically". That held because `element_kind` was a **literal inside each view**. With one view it arrives as a **bound parameter**, and `element_kind = $1` does not imply `element_kind = 'conductor'` in a generic plan — so the set becomes one Postgres may decline to use, silently and only under load. One GiST partial on `is_deleted` alone, plus `elements_project_kind_idx` widened to `(project_id, element_kind, category_id)`, which is the shape the editor actually asks. Separately and **not fixed here**: the bbox predicate is `ST_Transform(geom,3857) && box`, so no index on `geom` (27700) can serve it — true before this work, logged in `docs/improvement-opportunities.md` |
| 27 | **Kinds are mechanics, not fibre; keys are code, labels are configuration** (Dave, 2026-09-20) | `duct` and `cable` become **`conduit`** and **`conductor`** — what the thing does rather than what one sector calls it. `structure`, `equipment` and `area` were already neutral and are untouched. New `planner.element_kinds` carries an admin-editable `kind_label`, seeded with the fibre vocabulary, so this instance reads exactly as it did while a water client renames them to Sleeve and Main with no migration. The keys stay code because decision 3 makes kind the thing that fixes relationship mechanics — an admin adding a sixth would be adding a name with no behaviour behind it — so the Kinds pane has **no Add and no Delete**. The CHECKs become FKs, and the kind list stops being a hardcoded array in `admin_planner.js` and a second one in `admin_save.php`. **Category keys are unchanged**: the category is still *Duct* because that is what a fibre planner calls it, and the kind underneath it is the mechanic. That separation is the design working |
| 28 | **Statuses scope to categories, with a per-category default** (Dave, 2026-09-20) | One flat list of seven today, with each layer carrying a hardcoded subset in `editable_fields` — migration 077's comment called offering *Blocked* on a cable nonsense and deferred it here. `planner.element_status_categories`, same shape as `element_field_types`: **no rows means every category**. The catch found while designing it: `Planned` is the single global `is_default` and `fn_elements_derive()` reads it, so scoping alone would default a new blockage to a status its category no longer offers. The scoping row therefore carries `is_default` **per category**, with the global flag as fallback — which is what migration 073 already implied by migrating null-status blockages to `Blocked` rather than to the default |
| 29 | **Permissions are uniform and stay that way** (Dave, 2026-09-20) | Edit all, read all, or no access — never edit X but not Y. `map_get_v2.php` already gates at schema granularity (`'planner' => 'planner'`), which is exactly that, so no per-category permission work is needed now or when categories become admin-created |
| 30 | **Attribute fields are scoped to CATEGORIES, not types** (Dave, 2026-09-21) | Migration 069 built `element_field_types` so a field could be limited to particular element *types*. Wrong grain, and the reason is arithmetic: **41 types and rising**, each needing the same answer ticked individually to express what is almost always one intent — *this applies to cables*. Very easy to get wrong, and it reads as a poor build. **The bug that proves it:** 079 and 083 stored one row per type, selected by category *at migration time*. 079's own comment claims "a new cable type therefore inherits the cable fields automatically" — it does not; what was stored was a snapshot of the types existing that day, and nothing copies scoping to a type created later. A twelfth equipment type added in admin would have carried no Reference, no Customer ID, no PIANOI Reference and no Address. At **category grain the problem cannot arise**: a type belongs to a category, so it inherits by construction — nothing to copy, nothing to forget. **A genuinely different field set is still expressible: it is a new category**, which is what categories are already for (Duct and Subduct are one kind and two categories), and it gets its own map layer and styling rather than a hidden per-type exception. Migration 086 collapses the rows to their distinct categories — lossless, because every one was inserted by selecting whole categories, and asserted before the old table is dropped. It also **deletes client machinery**: `planner_form.php` resolves scope entirely, so `type_ids`, `applyPlannerFieldScope()`, the wrapper `data-type-ids` and the type-change listener all go |
| 31 | **Port templates live in the Type modal; there is no Ports tab** (Dave, 2026-09-21) | `element_type_ports` is `UNIQUE (type_id, port_group)` with an FK to one type, so **nothing is ever shared between types** — a 1:8 splitter and a 1:16 splitter each carry their own rows and reuse nothing. It is a one-to-many *detail* of a type, not a library, and it had a rail tab: a tab switch and a filter to answer a question about the type in front of you, a filter listing types that cannot hold a template, and a flat list across types where only within-type order means anything (which made a cross-type drag look broken when it was correctly snapping back). It is rows rather than columns on `element_types` because a type can have SEVERAL groups — a splitter is `input × 1` **and** `output × 8` — and columns would need a fixed number of slots for something with no fixed number. So it moves inside the type editor, matching the **Dropdown Options** panel in the field modal: rows buffered client-side, posted with the parent, committed in one transaction. `plannerWriteTypePorts()` replaces the whole list rather than diffing — **safe only until Phase 6**, when generated ports carry the template they came from and re-creating one would orphan every connection made against it |
| 32 | **The Type modal shows only what its kind can carry** (Dave, 2026-09-21) | It asked every type for everything: a chamber for its capacity unit, a splitter for its routing weight, a blockage whether it is the schematic root. Fields that cannot apply are **absent, not disabled** — a control that can only ever be left blank still reads as an unanswered question. `structure` gets nothing beyond the basics (a chamber houses things; nothing routes *through* it); `equipment` gets ports and the root flag; `conduit` gets capacity and routing weight; `conductor` gets ports; `area` gets routing weight. **Unit before count**, with the count disabled until a unit is chosen: `element_types_capacity_needs_unit` refuses a capacity with no unit, so asking for the number first invites a value the database will reject — and picking what is counted first is what makes "leave it blank where the name states a dimension" legible, since 12:8 is 12mm/8mm and not twelve of anything |
| 33 | **`type_key` is editable; `category_key` and `status_key` are not** (Dave, 2026-09-21) | The odd one out, and deliberately. **Nothing stores `type_key`** — elements carry `type_id`, the attribute form rebuilds its dropdown from the registry on every load, and `planner_save.php` resolves the posted key against the registry as it stands — so a rename orphans no data. What keys on the string is *presentation*: `vw_cable_export.cable_type`, the Phase 7 schematic's Cytoscape class names, and the GeoServer SLD filters. A rename therefore changes what a report says and can leave an SLD matching nothing, which is what the modal warns about, but it cannot lose anything. An empty key is refused, since that would orphan all three at once |
| 34 | **Port groups narrow by kind, not by type** (Dave, 2026-09-21) | The five names are a CHECK on `element_type_ports`, so the vocabulary is already code; the panel narrows it to what a kind can sensibly terminate — `conductor` → `fibre` only, `equipment` → `input`/`output`/`splice`/`connector`. **It cannot go finer than kind and does not pretend to**: within equipment a splitter wants input/output, a closure wants splices and a patch panel wants connectors, and the registry cannot tell them apart. So a splitter can still be given a splice group; narrowing removes what is wrong for *every* member of a kind, not what is wrong for one type. Presentation only — the server still relies on the CHECK, because a rule it cannot state precisely is worse than the one it has |
| 35 | ~~**Type-level specs need their own field system, not more columns**~~ (Dave, 2026-09-21) — **SUPERSEDED by 36** | A duct's bore diameter belongs to the **type** — every 1W duct is the same bore — so it cannot be an element field, and today it survives only inside the type *name* (`12:8 Mono`, `7mm/3.5mm`), legible to a person and invisible to every query. Fixed columns were rejected: `bore_mm`/`outer_mm` is a fibre-shaped answer on a registry this build has spent its whole length making sector-neutral, and the next sector wants kW or pressure rating instead. The answer is the element field meta one level up — admin-defined fields on a TYPE, scoped by kind or category, stored EAV. **Its own chunk**, because it is a subsystem and it is what the asset module needs to work outside fibre at all |
| 36 | **Type dimensions are columns, because the planner computes with them** (Dave, 2026-09-22) | Decision 35 proposed admin-defined fields on a type, stored EAV. Reversed on the strength of what the values are FOR. **A field the tool must compute with cannot be freeform:** Phase 5 asks whether three subducts fit inside a 12:8, and a fit check against an admin-defined field would have to find it BY NAME, with nothing guaranteeing it exists on every conduit type, nothing guaranteeing millimetres over inches, and nothing stopping three admins creating Bore, Inner Dia and ID. That is string-keyed lookup, the fragility this build has spent its length removing. **The line: anything the planner REASONS about is a column; anything that is only documentation can be freeform later.** 35's worry about fibre-shaped columns is answered by GENERIC NAMES rather than by freeform fields -- an inner diameter is not a fibre concept, and `capacity` + `capacity_unit` already carries 4 ways, 6 microducts and 144 fibres through one pair. Migration 091 adds `outer_diameter_mm` and `inner_diameter_mm`, seeding nothing |
| 37 | **Containment is an ordered list, not a parent pointer** (Dave, 2026-09-22) | `element_routes` holds a linear element's path; `parent_element_id` holds a point or area element hosted by another (a closure in a chamber, equipment within/on a structure). **A subduct in a duct is a route row, never a parent.** Settled by a case Dave drew: a subduct running CH-A to CH-C passes THROUGH CH-B and spans two duct sections, and a cable running CH-A to CH-D crosses two subducts. Neither has a single parent, so a `parent_element_id` naming the container could only ever be one of two and wrong half the time -- and anything counting parents instead of routes would report a different capacity, the double-counting trap CLAUDE.md already documents for project premises. **The nesting is not stored as a chain**: each element carries its own flat list of containers and depth comes from composing them (cable to subduct to duct), which is why there is no depth limit. A stretch's container may be any level -- a cable bare in a duct between two subduct runs is a row like any other -- and a direct-buried stretch is simply an absent row. **Sequence runs from the A end**, which is only unambiguous because A is the first vertex (chunk 7) |
| 38 | **Positions are numbers, stored absolute; colour is derived, later** (Dave, 2026-09-22) | Which microduct a cable occupies, and which fibre connects to which port, are recorded as **integers** -- `container_position` on a route row, an absolute fibre number in Phase 6. **The colour is a display lookup, not the allocation** (Dave's clarification, 2026-09-22): a stored number-to-colour map per standard (TIA-598, IEC 60304, BT CW1308), read only to render. It has to be that way round because a colour depends on the standard -- the same fibre is a different colour under each -- so only the number is standard-independent and safe to allocate against. **Adding a standard is then inserting rows**, not changing schema or touching an allocation. Dave's experience is that planning and build use numbers throughout; the 7W combi datasheet that prompted the question is four pages of dimensions and clearances and never mentions colour. **Absolute, not decomposed**: store fibre 137, not tube 12 / fibre 5 -- a 24F is 2x12 or 4x6 depending on product, so tube identity is derivation and baking it into rows would mean rewriting them all when the structure turns out different. Colour needs a shared library the type references (the `map_styles` shape) plus a per-type group size, and adding it later migrates nothing, because the numbers already stored are what the derivation consumes |
| 39 | **The attribute panel becomes a docked, tabbed shell; fibre allocation leaves it** (Dave, 2026-09-22) | `#sidebarAttributes` is 300px, absolute and OVERLAYS the map — fine for a dozen fields, structurally unable to hold what is queued. ~~**Docked, not overlaid**~~ — **tried and reverted** (Dave, 2026-09-22). Docking narrows the map, which slides everything on screen at the moment somebody has clicked a feature; compensating the centre for that still jumped. **The map must not move when a panel opens.** The panel stays an overlay, and the one case worth moving for — the selected element sitting underneath it — is handled on its own by panning just that element clear, in pixels, since being covered is a fact about the screen. The panel is still a sibling of `#mapOL` rather than a child, because `.map-div` is `overflow:hidden` and clips anything that needs to sit outside it. **Vertical rail for the tabs** (shell 2 of three prototyped): it survives collapse so any section is one click from anywhere, matches Module Management, spends no vertical space, and takes a sixth tab as another row rather than a wrapped strip. It must hide itself when there is only one tab, which is what keeps the change ADDITIVE — `#sidebarAttributes` spans six CSS files, three templates and four JS files across project, account, stocklist, opportunity and `?do=map`, so a single-tab panel has to render as it does today. **Fibre allocation is NOT a tab**: if it is drag-to-connect rather than a table of dropdowns, it is a canvas wanting width AND height, which is the opposite of a side panel. Making it a separate workspace removes the 560px pressure entirely and lets the panel stay narrow permanently. Cytoscape is already in the stack and Phase 7 is the schematic rebuild — check whether the two share a renderer before building either. **Routing and Contents are separate tabs**, being `element_routes` read in opposite directions: what am I inside, versus what is inside me. A duct has only the second, a cable only the first, a subduct both |

## Build

### Phase 0 — Schema foundation ✅

**Prerequisite — pre-flight audit, before any migration is written.**

`sql/planner_preflight_audit.sql` — read-only, run against the live database, output reviewed
before Phase 0's migrations exist. It belongs in `sql/` and not `db/` because `migrate.php` globs
`db/*.sql` and would apply it, the same reason `sql/util_delete_dynamic_field.sql` lives there.

It must report:

1. Distinct values of `point_type`, `structure_type`, `cable_type`, `duct_type`, `subduct_type`,
   `blockage_type` and every `*_status` column, with row counts — the registry is then built from
   reality rather than from the `editable_fields` JSON. **Four of the six status columns have no
   UI at all**: only cables (`Planned` / `Surveyed` / `Built`) and blockages
   (`Active` / `Resolved`) expose a status in the attribute form, so whatever
   `point_status`, `structure_status`, `duct_status` and `subduct_status` hold is legacy or
   imported and cannot be reasoned about from the form definition. This is the report the shared
   status list is built from.
2. Row counts per source table, to check against post-migration counts.
3. `a_end` / `b_end` / `parent_*_id` values with no matching row (orphans to resolve before, not during).
4. `GeometryType()` distribution per table **and per geometry column** — decides whether the
   views need `ST_Multi()` and which cast each takes.
5. `ST_SRID()` distribution, and any invalid geometry — the SRID check will reject anything
   that is not 27700.
6. Rows with null or blank type values, which have no home in a registry-backed model.
7. Duplicate `*_ref` / `customer_*_id` values, which affect the naming decisions in Phase 1.
8. Whether `prospector.network_routes` exists, and whether it holds rows.
   `map_feature_save.php:184` and `map_boundary_update.php:126` both set
   `$layerTable = 'prospector.network_routes'`, but the baseline has only `network_routes_delete`
   and no `public.map_layers` row names it. Almost certainly a dead branch to delete rather than
   migrate — but that is the audit's call, not an assumption.
9. `pia_blockages` row counts **by `blockage_status`**, which decides the size of the auto-route
   fix in Phase 1 step 7.

10. **`geom` versus `geom_user` on the three linear tables.** `network_cables`, `network_duct`
    and `network_subduct` each carry **two** geometry columns; points, structures and blockages
    carry one. Report, per table: rows with `geom_user` populated, rows with `geom` populated,
    rows with both, and rows where the two differ (`NOT ST_Equals`).

    **Decided** (decision 18): `geom_user` becomes `planner.elements.geom` and the original
    `geom` is dropped. The evidence supports it — `map_feature_save.php` writes only `geom_user`,
    all three `public.map_layers` rows serve `geomfield=geom_user`, and `network_topo_link`
    measures `ST_Length(geom_user)`, while nothing in `www/` writes or reads the bare `geom`.

    The report is therefore not a gate. It exists so that what is dropped is recorded before it
    goes — the difference between a decided discard and a silent one.

Two further relations the audit must confirm rather than assume, because the map is configured in
data and GeoServer/QGIS configuration is not in git: `prospector.vw_cabinet_locations` (no
consumer anywhere in `www/`) and `prospector.estimates` (likewise). Check `public.map_layers` and
the GeoServer layer list before concluding either is dead.

#### Audit outcome — run 2026-09-20

`sql/planner_preflight_audit.sql` was run against the live database and its output is
pasted into the file beneath each query. It grew two reports beyond the plan's ten:
**12** (stored versus measured length) and **13** (triggers, defaults, constraints and
functions on the source tables — logic attached to a table is invisible to a grep of
`www/` and would otherwise be dropped silently). What it changed:

- **441 elements to migrate** — points 178, cables 168, subduct 39, blockages 38, duct 13,
  structures 5 — plus 442 rows of `network_cables_history`.
- **`prospector.network_routes` does not exist.** So `map_feature_save.php:184` and
  `map_boundary_update.php:126` both write to a relation that was never there: the
  `PlanNetwork` branch is dead code to delete, not migrate. `network_routes_delete` exists
  and holds 0 rows.
- **The blockage status premise below was wrong** — see the correction in Phase 1 step 7.
- **Nine `public.map_layers` rows name `prospector`, not six.** Three are GeoServer WMS
  layers the plan never accounted for: `layer_id` 15 `project_meet_me_points` over
  `network_points`, 24 `piaBlockages` over `pia_blockages`, 27
  `planned_cables_all_projects` over `network_cables`. They need no new views — the
  per-kind views cover all three — but they do need re-pointing in GeoServer, which is
  not in git. See Phase 3.
- **Two cables are `GEOMETRYCOLLECTION`**, which no cast can render and the geometry
  trigger rejects; 8 rows hold invalid geometry (2 cables, 5 subducts, 1 blockage
  polygon). Repaired during migration with `ST_CollectionExtract` and `ST_MakeValid`,
  each one reported (Dave, 2026-09-20).
- **Seven rows had no resolvable project** — fixed at source before Phase 1. The migration
  asserts zero and fails loudly if any reappear.
- **39 rows carry a null type**, and `type_id` is `NOT NULL`. Each affected category gets
  an *Unspecified* type, `is_seeded`, with no capacity and no port template (Dave,
  2026-09-20). This is not the "Unknown bucket" step 2 forbids — that rule is about
  unmatched *values*; these rows have no value at all.
- **`L1` appeared in `point_type`** (1 row), not in the seed export. Fixed at source, so
  the registry stays at the 11 equipment types. `CMJ(1:4-1:8)` is absent, confirming the
  2026-09-20 correction held.
- **`geom` is 100% NULL on all three linear tables** — decision 18 discards nothing.
- **The length column under-reports badly.** No row disagrees with its measured length,
  but 61 of 168 cables have geometry and no stored length, and all 13 ducts and all 39
  subducts have none. Today's project total-cable figure is **41,236 m against a measured
  49,717 m**. Decision 22 fixes it, and the displayed number will rise about 20%.
- **Duplicate `customer_*_id` values are expected, not a fault** (Dave): a cabinet planned
  in one project is invisible in another and has to be recreated, so the same reference
  legitimately appears several times. The assets module is what resolves it.

New `planner` schema. Migrations start at **066** (`db/065_abp_main.sql` is applied).

**Registries.**

```sql
CREATE TABLE planner.element_categories (
    category_id     serial PRIMARY KEY,
    category_key    varchar(50) NOT NULL UNIQUE,
    category_name   varchar(100) NOT NULL,
    element_kind    varchar(20) NOT NULL
                    CHECK (element_kind IN ('structure','equipment','duct','cable','area')),
    geometry_kind   varchar(20) NOT NULL
                    CHECK (geometry_kind IN ('point','line','polygon')),
    display_order   integer,
    is_active       boolean NOT NULL DEFAULT true,
    is_seeded       boolean NOT NULL DEFAULT false,
    created_at      timestamp without time zone DEFAULT now(),
    created_by      integer
);
```

```sql
CREATE TABLE planner.element_types (
    type_id         serial PRIMARY KEY,
    category_id     integer NOT NULL REFERENCES planner.element_categories(category_id),
    type_key        varchar(50) NOT NULL,
    type_name       varchar(100) NOT NULL,
    capacity        integer,                       -- 4 ways, 96 fibres, 8 ports
    capacity_unit   varchar(20),                   -- ways | microducts | fibres | ports | cores
    routing_weight  numeric,                       -- per-type default avoidance multiplier (decision 13)
    is_network_head boolean NOT NULL DEFAULT false, -- schematic root; replaces the hardcoded 'L0' (decision 14)
    display_order   integer,
    is_active       boolean NOT NULL DEFAULT true,
    is_seeded       boolean NOT NULL DEFAULT false,
    created_at      timestamp without time zone DEFAULT now(),
    created_by      integer,
    UNIQUE (category_id, type_key)
);
```

`routing_weight` is the **per-type default** avoidance multiplier, decision 13. It is not
replacing a hardcoded value — `project_auto_route.php` reads `routeCivilsAvoidance` and
`routePIABlockageAvoidance` off the request, so the multipliers are already set by the planner per
run. What the registry adds is a sensible starting value per type (a no-dig blockage weighting far
higher than a hard dig) instead of the planner having to remember one, and a router that joins the
type registry rather than naming `pia_blockages` specifically. **The per-run controls stay and
still override.**

**Port templates** — how a type declares what it terminates, and only for things that terminate
fibre. A 1:8 splitter is two rows (input × 1, output × 8); a 96F cable is one row (fibre × 96).

**Duct and subduct get no port template.** How many subducts fit in a duct, or microducts in a
combi subduct, is containment capacity — the `capacity` / `capacity_unit` columns above, counted
against `element_routes` — not ports. Capacity asks what fits through a conduit; ports ask what a
fibre joins to. Using ports for ducts would put a row per way against every duct in the network
and leave `vw_element_capacity` with two answers to one question.

```sql
CREATE TABLE planner.element_type_ports (
    template_id     serial PRIMARY KEY,
    type_id         integer NOT NULL REFERENCES planner.element_types(type_id),
    port_group      varchar(30) NOT NULL,          -- input | output | fibre | splice | connector
    port_count      integer NOT NULL,
    naming_pattern  varchar(50),                   -- e.g. 'F{n}', 'OUT{n}'
    display_order   integer
);
```

**Statuses** — one shared list across all element kinds, following
`docs/2026-08-03-admin-status-management.md`: add, rename, reorder, retire, style token, a status
in use cannot be deleted only deactivated, and the default status can be neither.

**Styles** — table-driven, seeded by migration, **no admin UI in this build**. Type supplies the
base; a row with a `status_id` supplies a *partial* override, so a status sets only what it changes
(planned = dashed) without restating colour and width.

```sql
CREATE TABLE planner.element_styles (
    style_id        serial PRIMARY KEY,
    type_id         integer REFERENCES planner.element_types(type_id),
    status_id       integer REFERENCES planner.element_statuses(status_id),
    symbol          varchar(50),
    fill_colour     varchar(20),
    stroke_colour   varchar(20),
    stroke_width    numeric,
    stroke_dash     varchar(30),
    opacity         numeric,
    label_field     varchar(50),
    min_zoom        integer,
    display_order   integer,
    UNIQUE (type_id, status_id)
);
```

Resolution order: `(type, status)` then `(type, NULL)`, with NULL columns inheriting from the base.

**The elements table.**

```sql
CREATE TABLE planner.elements (
    element_id          bigserial PRIMARY KEY,
    project_id          integer NOT NULL REFERENCES projects.projects(project_id),
    element_kind        varchar(20) NOT NULL,      -- denormalised from category, trigger-maintained
    category_id         integer NOT NULL REFERENCES planner.element_categories(category_id),
    type_id             integer NOT NULL REFERENCES planner.element_types(type_id),
    status_id           integer NOT NULL REFERENCES planner.element_statuses(status_id),
    element_name        varchar(150),
    element_ref         varchar(50),
    geom                public.geometry,
    sld_x               double precision,          -- schematic node position (decision 14)
    sld_y               double precision,
    parent_element_id   bigint REFERENCES planner.elements(element_id),
    parent_relation     varchar(20)
                        CHECK (parent_relation IN ('contained','attached')),
    a_end_element_id    bigint REFERENCES planner.elements(element_id),
    b_end_element_id    bigint REFERENCES planner.elements(element_id),
    ownership           varchar(20) NOT NULL DEFAULT 'owned'
                        CHECK (ownership IN ('owned','leased','third_party','shared')),
    is_deleted          boolean NOT NULL DEFAULT false,
    created_datetime    timestamp without time zone DEFAULT now(),
    created_user        integer,
    modified_datetime   timestamp without time zone DEFAULT now(),
    modified_user       integer
);
```

Constraints and indexes:

- `CHECK (public.ST_SRID(geom) = 27700 OR geom IS NULL)` — one SRID per column is mandatory when
  the column is untyped.
- Trigger enforcing the geometry type against the category's `geometry_kind`, and keeping
  `element_kind` in step with `category_id`. Type enforcement moves from typmod to a trigger; this
  is the accepted cost of decision 1.
- **Partial GiST indexes per kind** — `CREATE INDEX ... USING gist (geom) WHERE element_kind = 'cable'`
  and one per kind. Every layer query filters on kind, so the planner will use them.
- btree on `(project_id, element_kind)`, `type_id`, `a_end_element_id`, `b_end_element_id`,
  `parent_element_id`.

**Relationships.**

```sql
CREATE TABLE planner.element_routes (
    route_id              bigserial PRIMARY KEY,
    element_id            bigint NOT NULL REFERENCES planner.elements(element_id),
    sequence_no           integer NOT NULL,
    container_element_id  bigint REFERENCES planner.elements(element_id),
    external_dataset      varchar(50),             -- e.g. openreach_pia
    external_feature_id   varchar(100),
    external_geom         public.geometry,         -- snapshot; survives their refresh
    provenance            varchar(20) NOT NULL
                          CHECK (provenance IN ('routed','derived','manual')),
    confidence            numeric,                 -- derived rows only
    is_stale              boolean NOT NULL DEFAULT false,
    created_datetime      timestamp without time zone DEFAULT now(),
    created_user          integer,
    CHECK ( (container_element_id IS NOT NULL)::int
          + (external_feature_id  IS NOT NULL)::int = 1 ),
    UNIQUE (element_id, sequence_no)
);
```

One table serves both "our cable through our duct" and "our subduct through their PIA sections",
so *what runs through this duct* and *which PIA sections does this subduct use* are the same query.

**Field meta.** `planner.element_fields` plus the five EAV value tables and their history tables,
following the house pattern. Type scoping via a link table so a field can apply to several types:

```sql
CREATE TABLE planner.element_field_types (
    field_id    integer NOT NULL REFERENCES planner.element_fields(field_id),
    type_id     integer NOT NULL REFERENCES planner.element_types(type_id),
    PRIMARY KEY (field_id, type_id)
);
```

A field with no rows here applies to all types. This is the type-scoped dynamic fields item from
`docs/improvement-opportunities.md` and the research doc — the first module to have it.

**Journal, history and delete** (decision 15). `planner.element_journal` plus
`planner_journal_save.php`, matching every other module — and `planner.elements_history` with the
standard triggers from `docs/2026-07-19-entity-history-triggers.md`. **All five kinds get history,
which four of them have never had**: only `network_cables` has a history table today, so a point,
structure, duct or subduct edit has been unrecoverable.

Delete is soft — `is_deleted` plus a journal entry, and every view filters it out. There is **no
archive list, no restore screen and no `planner_approve` pseudo-module** in this build; if they
are wanted later they follow `docs/module-delete-restore-pattern.md`. Today `map_feature_save.php`
deletes the row outright, so this is a behaviour change: a deleted element's journal, history and
any future asset promotion link all keep resolving.

`elements_history` is not only for the audit trail — `fieldOptionUsageCounts()`
(`global_functions.php:2313`) walks `entity_table` **and** `entity_history` when counting whether
a dropdown option is still in use, so without it a static planner field's option-delete guard
would under-count.

**Views — the only read surface.** Each casts its geometry so `geometry_columns` reports a real
type and SRID, which is what makes GeoServer work:

| View | Filter | Geometry cast |
|---|---|---|
| `vw_elements_equipment` | kind = equipment | `geometry(Point, 27700)` |
| `vw_elements_structure_point` | kind = structure, geometry_kind = point | `geometry(Point, 27700)` |
| `vw_elements_structure_area` | kind = structure, geometry_kind = polygon | `geometry(MultiPolygon, 27700)` |
| `vw_elements_duct` | kind = duct | `geometry(MultiLineString, 27700)` |
| `vw_elements_cable` | kind = cable | `geometry(MultiLineString, 27700)` |
| `vw_elements_area` | kind = area | `geometry(MultiPolygon, 27700)` |

Wrap lines and polygons in `ST_Multi()` so the cast cannot fail on a multipart drawing. Every view
must stay a plain filter with casts — **no aggregation, no joins** — so a `project_id` or bbox
predicate reaches the base table and its partial index. This is the rule that
`vw_premise_claimants` already documents the cost of breaking.

Capacity is a separate, correlated view (`vw_element_capacity`): type capacity against a count of
routing links, written with `LATERAL` so it stays filterable.

### Phase 1 — Data migration from `prospector`

1. **Derive the type registry** from `reference-data/project_map_elements_types_seed.json`
   (decision 17) — the live export of `public.map_layers.editable_fields` for layer_ids 36–41.
   Use that file, not `db/archive/000_map_layers_corrections.sql`: the archive migration is the
   value as seeded and can have drifted from what is in the database. Values:
   - Equipment (`point_type`, 11): `MM` Meet Me Point, `L0` L0 - Cab, `DJ` L1 - DJ/SN,
     `1:32` Splitter 1:32, `1:8` Splitter 1:8, `1:4` Splitter 1:4, `CMJ`, `CMJ(1:4)`, `CMJ(1:8)`,
     `FDN`, `PBO`
   - Structures (`structure_type`, 3): `CAB` Cab, `JB` Chamber/Jointing Box, `SB` Splitter Box
   - Cables (`cable_type`, 9): `1F`, `2F`, `4F`, `8F`, `12F`, `24F`, `48F`, `96F`, `144F`
   - Duct (`duct_type`, 4): `1W` 1 Way, `2W` 2 Way, `3W` 3 Way, `4W` 4 Way
   - Subduct (`subduct_type`, 5): `12:8` 12:8 Mono, `6W` 6W Combi, `7W` 7W Combi, `7mm/3.5mm`,
     `Oth` Other
   - Blockage (`blockage_type`, 2): `Public`, `Private`
   - Statuses, the only two sets with a UI: cables `Planned` / `Surveyed` / `Built`, blockages
     `Active` / `Resolved`

   The stored value is the registry's `type_key` and the display text its `type_name`, so the
   migration matches on the existing column value and nothing has to be re-keyed by hand.

   **`CMJ(1:4-1:8)` was retired at source on 2026-09-20** (Dave): it was never a distinct object,
   only a way of styling a closure carrying both splits. It is out of the seed export and the one
   row holding it was re-typed to `CMJ(1:8)`, which is why equipment is 11 types and not 12.

   Worth keeping as the rule, because the next one will look the same: **a distinction that exists
   only to change how something is drawn has no home in the type registry.** Type carries the spec
   (decision 3) and `element_styles` resolves `(type, status)` — so a presentation-only variant is
   a style row, or it is nothing. Creating it as a type gives it a `capacity`, a port template and
   a place in every capacity answer, none of which it has any business having.

2. **Reconcile against live data.** The columns are free text, so values may exist that the JSON
   never listed — and the four status columns with no UI (audit report 1) are where to expect
   them. The migration must report distinct values not matched by the derived registry and
   fail loudly rather than silently creating an "Unknown" bucket. This is also the check that
   catches a stray `CMJ(1:4-1:8)` in any environment where the 2026-09-20 correction was not
   applied.
3. **Map into categories.** Duct → category *Duct*; subduct → category *Subduct* (both kind `duct`,
   which is decision 4 in action). Equipment splitters, joints and nodes into their own categories.
   The seed reproduces **today's categories and nothing finer**, which is what keeps Release 1
   behaviour-unchanged. Categories and types are admin-managed and a type can be moved between
   categories sharing a `geometry_kind`, so splitting microduct out of subduct — or any other
   refinement — is an admin change whenever it is wanted, not something the migration has to
   anticipate.
4. **Seed port templates and capacities — two different things.**
   - **Port templates**, for fibre-bearing types only: cable `nF` → fibre × n; splitter `1:n` and
     `CMJ(1:n)` → input × 1, output × n. Duct and subduct get none.
   - **Capacity** on the type, where the label states one: duct `1W`–`4W` → 1–4 `ways`;
     `6W` / `7W` Combi → 6 and 7 `microducts`.
   - **Where the label states a dimension rather than a count, seed no capacity.** `12:8` is 12mm
     outer / 8mm inner and `7mm/3.5mm` is 7mm / 3.5mm — both single-bore, neither a quantity.
     `Oth` Other is unknown. Leave `capacity` NULL on all three: NULL reports as *not known*,
     which is true, where a guessed number reports as fact.

   This step exists because reading `12:8` as a count was exactly the mistake made while writing
   this plan (Dave corrected it, 2026-09-20). The dimensions stay legible in the type name as they
   are today; nothing in this build queries them, so nothing here stores them separately.

   A wrong capacity is not cosmetic — it is the denominator every spare-capacity answer divides
   by — but it is seed data, editable in admin, not something baked into the schema.
5. **Migrate rows** with an id map per source table, then rewrite `a_end`/`b_end` and the parent
   references through it. **`planner.elements.geom` takes `geom_user`** for cables, duct and
   subduct, and `geom` for points, structures and blockages; the original `geom` on the three
   linear tables is **not migrated** (decision 18). Audit report 10 records what it held first —
   the renamed source tables keep it recoverable regardless. `network_cables` history is migrated into `planner.elements_history`
   where it maps cleanly and retained in place regardless; the other four kinds have no history to
   carry (decision 15). The new journal starts at migration.
6. **Move non-load-bearing columns to dynamic fields** — `pianoi_ref`, `customer_*_id`,
   `point_address`, blockage contact details and requested access date, and the two free-text
   fibre allocation columns (retained verbatim until Phase 6 replaces them). **`sld_x` / `sld_y`
   are not in this set** — they are columns on `planner.elements` (decision 14).
7. **Migrate `pia_blockages`** into elements of kind `area`, category *Blockage*, with
   `routing_weight` carrying a default avoidance per type.

   **The status mismatch is real but not what this plan said it was — corrected from audit
   reports 1 and 9, 2026-09-20.** The plan asserted that the only values the form can produce
   are `Active` and `Resolved`, therefore that an Active blockage is not avoided and the
   feature has been inverted for as long as the dropdown has existed. **There is not one
   `Active` row in the database.** The live vocabulary is:

   | `blockage_status` | rows | avoided today |
   |---|---|---|
   | `Blocked` | 19 | yes |
   | (null) | 13 | yes, via the `coalesce` |
   | `Cleared` | 3 | no |
   | `NoAccess` | 2 | **no** |
   | `Resolved` | 1 | no |

   `Blocked`, `Cleared` and `NoAccess` are values the attribute form cannot produce — but
   which `piaBlockageStyles` in `map_layer_utils.js` already has style keys for. The styles
   knew about the legacy vocabulary and the dropdown did not. So today's routing is mostly
   **correct**: 32 of 38 avoided, and the two skipped classes are Cleared and Resolved, which
   is right. Applying the fix as originally written would have been a regression.

   What actually lands (Dave, 2026-09-20): seed the **live** values — `Blocked`, `Cleared`,
   `NoAccess`, `Resolved` — and let the form offer all four instead of `Active`/`Resolved`.
   Auto-route avoids `Blocked`, `NoAccess` and null; skips `Cleared` and `Resolved`. **Only
   `NoAccess` changes side, 2 rows**, and it changes in the direction that is obviously right:
   no access is a reason to route round something. `Active` is not seeded — nothing uses it.
   The join still moves off the `'Blocked'` string literal onto the status registry.

   **Null-status blockages migrate to `Blocked`**, not to the default status. That is exactly
   what `coalesce(b.blockage_status, 'Blocked')` has always asserted they mean, so it carries
   the existing interpretation across rather than inventing a new one.
8. **Seed catalogues** are exactly what step 1 derives — the client's own values, flagged
   `is_seeded` so a rename or deletion does not fight the next update. **No invented range** (no
   1F–288F) and **no electrical set**: the module is type-driven and proves it by being seeded
   from data, not by shipping a taxonomy for a sector with no customer (decision 17).
9. **Rename each source table in place as it is migrated** (decision 16):
   `prospector.network_points` → `prospector.network_points_migrated_<date>`, and the same for
   `network_structures`, `network_cables`, `network_cables_history`, `network_duct`,
   `network_subduct`, `pia_blockages`, and any of `network_routes` / `network_routes_delete` the
   audit finds live. Drop and recreate the dependent views (`vw_network_cable_export`,
   `network_topo_point`, `network_topo_link`, and `vw_cabinet_locations` if the audit finds it
   live) against `planner.elements` rather than leaving them pointing at renamed tables.

   **The `prospector` schema itself stays.** A reader the audit missed then fails loudly on a
   missing relation instead of quietly returning stale rows — which a schema rename would not
   give, since it moves every reader at once. It also leaves the four `*_migrated_20260804`
   opportunity tables and `prospector.estimates` untouched; this build never examined them and
   should not rename them as a side effect. A follow-up job once the module is complete reviews
   the whole schema and backs up or drops it.

### Phase 2 — Module registration, permissions and admin

- `public.app_modules` row `planner`. The module renders **inside `projectedit`** and gets no
  top-level page of its own, so the only `routes.php` entry this build adds is
  `?do=admin_planner` (gated on `admin_planner`) — there is no `?do=planner` route to gate.
  `projectedit` keeps `'module' => 'projects'`. Two consequences follow, and both need building
  rather than assuming:
  - **Endpoint gating is the real gate.** Every `planner_*.php` endpoint calls
    `requireModuleAccess($pdo, 'planner', 'planner', <level>)` in addition to the projects check,
    because no route gate stands in front of them.
  - **The front end has to be told.** The planner tabs and layer group are hidden when the module
    is disabled or the user has no `planner` read, which means the enablement and permission
    level reach the page — through `global_vars.php`, the same channel every other server-side
    JS var uses. Without that the tabs render and every call behind them fails.

**Two permissions, and they are not the same job.** Using the planner and configuring it are
separate roles — a project planner draws the network, an admin decides what element types exist.

| Capability | Permission | Kind | Checked as |
|---|---|---|---|
| **View** planner layers, elements, connections and fibre allocation | `planner` read **and** `projects` read | Data | `requireModuleAccess($pdo, 'projects', 'projects', 'read')` and `requireModuleAccess($pdo, 'planner', 'planner', 'read')` |
| **Draw, edit, delete** elements; edit connections and fibre allocation | `planner` write **and** `projects` write | Data | the same two calls at `'write'` |
| Element categories, types, port templates, statuses (and styles if a UI is ever added) | `admin_planner` — *Planner Configuration* | Admin | `getModulePermission()` + `permissionSatisfies(…, 'write')`, via `admin_save.php`'s `$modePermissions` |
| Planner dynamic fields, in Module Management alongside every other module | `admin_fields` | Admin | as above |

**The roles this has to support** — these are the acceptance criteria:

| Role | Holds | Can |
|---|---|---|
| Governance / programme manager | `projects` write, `projects_approve` write, `planner` read | Approve or reject boundary changes; see the network, not edit it |
| Project manager | `projects` write, `planner` read | Edit the project; see the network, not edit it |
| Network planner | `projects` write, `planner` write | Edit the project and design the network |
| Catalogue admin | `admin_planner` write | Define element categories, types, port templates and statuses; **not** draw |

Read-only planner is a real UI state, not just a rejected save: the layers render and features are
selectable, the drawing and editing tools are absent, and attribute forms and the fibre allocation
grid are read-only. A user with no planner permission at all sees no planner layers or tabs, the
same as when the module is disabled.

`admin_planner` is a new admin module following the `admin_<area>` convention
(`admin_users`, `admin_companies`, `admin_map_layers`). It is deliberately **not** folded into
`admin_fields`: configuring the network catalogue and managing form fields are different jobs, and
a client should be able to grant a GIS admin the element types without also granting every
module's form fields.

It must be **wired up**, not merely declared. `admin_map_layers` and `admin_dist_analysis` are in
`admin_users.js`'s `MODULES` but nothing checks them — both routes and all their `admin_save.php`
modes gate on `admin_fields` instead (logged under Security in
`docs/improvement-opportunities.md`). `admin_planner` must appear in `routes.php` for the
`?do=admin_planner` page **and** in `$modePermissions` for every planner config mode, or it becomes
the fourth grantable-but-inert key.

Dynamic fields stay under `admin_fields` because that page is genuinely cross-module — a planner
custom field is configured with the same permission as a project custom field.

**No `planner_approve` pseudo-module.** There is no approval flow in this build. If milestones
introduce one it gets added then, following `projects_approve`.

**Seed the new permission on upgrade.** Migration 006 records the trap: *no row = no access*, so
without a seed every existing role silently loses the new module on deploy. The migration must
insert `planner` at `write` for all existing roles, exactly as 006 did for `opportunities`,
`wayleave` and `strategy`. Existing planner users hold `projects` write today and would otherwise
be locked out of a module they have been using.

- **`map_get_v2.php`'s `$schemaModuleMap` (line 116) must gain `'planner' => 'planner'`.** This
  is the single wiring point that makes the role matrix above true, and it is easy to miss because
  the layers are configured in `public.map_layers` rather than in code. The map today maps
  `'prospector' => 'opportunities'`, left from when the opportunity module owned that schema — so
  **planner layers are currently gated behind the opportunities permission**: an
  opportunities-only user can read the network, and a projects-and-planner user without
  opportunities cannot. Until this line changes, a `planner` read grant renders no layers. It also
  closes the standing backlog item about `prospector` still being gated behind the
  `opportunities` permission in `docs/improvement-opportunities.md`.
- Record-level permission derives from the **project**, not the element, and is checked at the
  **same level as the action** — read to view, write to edit:
  `getItemPermission($dbh, $userID, 'project', $project_id)` combined through
  `permissionSatisfies()`, plus `requireNotDeleted($pdo, 'project', $project_id)` on every write,
  as `project_fibre_allocation.php` already does. So a read-only item permission on one project
  makes its planner data read-only even for a user with `planner` write.
- Registering the module means: `public.app_modules` row, `routes.php` entry, `admin_users.js`
  `MODULES` **and its roles-list label map**, and a row in `html_body_admin_users.php` — the same
  checklist CLAUDE.md gives for permission keys.
- **Admin surfaces:** element categories, types and port templates; statuses; dynamic fields.
  Statuses and fields follow the existing admin patterns. Styles get **no** UI.
**Where each admin surface lives.**

- **`?do=admin_planner` — Planner Configuration.** A new top-level admin page holding element
  categories, types, port templates and statuses. Gated on `admin_planner`.
- **`?do=admin_fields&module=planner` — Module Management.** Planner's dynamic fields only,
  alongside every other module's. Gated on `admin_fields`.

Putting statuses on the Planner Configuration page rather than in Module Management is what keeps
this build out of shared admin code. Per CLAUDE.md the Module Management **Statuses** area exists
only for modules in `dashboardModuleMap()`, and planner has no dashboard-able entity — no list
page, no manager, no expected completion date. Rather than add planner to that map (which would
mean filling all five per-module maps plus the `moduleOverview()` label, and producing a
meaningless dashboard card) or splitting the gate, statuses simply live with the rest of the
planner catalogue. **Planner is therefore not added to `dashboardModuleMap()`**, and its Statuses
and Dashboard areas in Module Management are correctly unavailable.

**Planner does join `fieldMetaModuleConfig()`**, so dynamic fields use the shared save helpers
(`metaFieldLookup()`, `metaFieldValueChanged()`, `metaStaticValueChanged()`) — the EAV shape is
identical, keyed on `element_id` instead of an entity id. The config row carries
`'schema' => 'planner'`, `'entity_table' => 'elements'`, `'entity_id' => 'element_id'`,
`'entity_history' => 'elements_history'` alongside the usual meta table names.

**Two things must land in the same change**, per CLAUDE.md's warning that adding a module to that
config switches consumers on rather than registering it inertly:

- `admin_load.php`'s `moduleOverview()` **label map** (line 786) needs `'planner' => 'Planner'`.
  The loop walks `fieldMetaModuleConfig()`, so a module present there and absent from the labels
  renders with no name in the `?do=admin` matrix.
- `statusModuleConfig('planner')` returns null, because it derives from `dashboardModuleMap()` and
  planner is deliberately not in it. The matrix therefore reports planner's **Statuses as not
  applicable while planner does have statuses** — they are on the Planner Configuration page.
  That cell needs to say so and link to `?do=admin_planner`, rather than leave a true statement
  about the map reading as a false one about the module.

The one extension needed is **type-scoping**, since no existing module scopes fields by a
sub-type. That is already an improvement-opportunities item and every module gains it.

Planner appears in the `?do=admin` module × area matrix; it gets no dashboard card.

### Phase 3 — Map layers, views and styles

- Replace the **nine** `prospector` rows in `public.map_layers` — not six. Audit report 11
  found three the plan never accounted for. Six are the editor's own vector layers, served
  through `map_get_v2.php` and re-pointed by migration; three are **GeoServer WMS layers**,
  whose `map_layers` row is re-pointed by migration but whose *GeoServer* configuration is
  not in git and must be changed by hand, per instance.

  | `layer_id` | `layer_name` | over | becomes | served by |
  |---|---|---|---|---|
  | 15 | `project_meet_me_points` | `network_points` | `vw_elements_equipment` | **GeoServer** |
  | 24 | `piaBlockages` | `pia_blockages` | `vw_elements_area` | **GeoServer** |
  | 27 | `planned_cables_all_projects` | `network_cables` | `vw_elements_cable` | **GeoServer** |
  | 36 | `PlanEquipment` | `network_points` | `vw_elements_equipment` | `map_get_v2.php` |
  | 37 | `PlanStructures` | `network_structures` | `vw_elements_structure_point` | `map_get_v2.php` |
  | 38 | `PlanCables` | `network_cables` | `vw_elements_cable` | `map_get_v2.php` |
  | 39 | `PlanDuct` | `network_duct` | `vw_elements_duct` | `map_get_v2.php` |
  | 40 | `PlanSubDuct` | `network_subduct` | `vw_elements_duct` | `map_get_v2.php` |
  | 41 | `projectPIABlockages` | `pia_blockages` | `vw_elements_area` | `map_get_v2.php` |

  The three WMS layers need **no new views** — the per-kind views cover all three. What they
  need is a GeoServer checklist, because a layer pointed at a renamed table fails *quietly*
  there: an empty layer, not an error. That is the one place decision 16's loud-failure
  guarantee does not hold.

  PlanDuct and PlanSubDuct both resolve to `vw_elements_duct` and are separated by
  `category_key`, which is decision 4 in action.

  Two further rows point at prospector views and belong to the opportunity module rather than
  this one: `vw_opportunity_routes` and `vw_opportunity_network_points` — see the last bullet.
- **`editable_fields` stays populated through Release 1** (decision 19), with its `property`
  values re-mapped to the new view column names. Phase 4 empties it in the same change that
  lands the meta-driven renderer. Keep the export in `reference-data/` as the record of what
  it held.
- Per-category views where a separate map toggle is wanted (Chambers and Poles as distinct
  layers); styles where the distinction belongs inside one layer. Views are cheap.
- Style resolution moves from `vector_style`/`vector_style_field` JSON into
  `planner.element_styles`, resolved type-then-status, and consumed by `map_layer_utils.js`.
- GeoServer: register the feature id for each view (`gt_pk_metadata` or a GeoServer SQL view),
  the same hand-configuration already done for the `projects.projects` WMS layer.
- Update the `opportunity.vw_opportunity_network_points` view (migration 025) and the opportunity
  map layer rows, which read `prospector.network_points` today.

### Phase 4 — Configuration made real

**Re-scoped 2026-09-20 (Dave).** The original Phase 4 was three bullets about the attribute
form. The real job is decision 23: Release 1 made the registry editable and nothing
downstream reads it, so the planner is configurable in its tables and fibre-shaped
everywhere else. Two of the original bullets are already done —
`planner_load.php` / `planner_save.php` exist and are the only writer, and drawing already
picks Point/LineString/Polygon from `layer_type`, which matches each category's
`geometry_kind`. What is left is making a category and a type mean something without a
developer.

Five chunks, each tested before the next starts.

**Chunk 1 — one view, and kinds as mechanics.** Migration 082.

- `planner.vw_elements` replaces the seven per-category views (decision 24); the four read
  views and `opportunity.vw_opportunity_network_points` are rebuilt against it with the
  catalog-driven dependent check 074 and 079 carry.
- `duct` / `cable` become `conduit` / `conductor`; `planner.element_kinds` holds the
  admin-editable labels; the CHECKs become FKs (decision 27).
- The five partial GiST indexes become one (decision 26).
- `map_get_v2.php` gains the `filter` parameter (decision 25).
- The nine `map_layers` rows re-point with `&filter={"element_kind":…,"category_key":…}`.
- Callers follow: `map_get_connection_points.php`, `opportunity_process.php`,
  `project_auto_route.php`, `project_load.php`, `project_fibre_allocation.php`,
  `admin_save.php`'s kind whitelist, `admin_planner.js`'s kind array.
- An **Element Kinds** pane in Planner Configuration — label, description and order only,
  no Add and no Delete.
- **A Release 1 defect found while testing this chunk** (Dave, test 5): `admin_save.php`'s
  `$allowedModes` contained **no planner mode at all**, and it is the gate at line 57,
  before `$modePermissions` is consulted. So every planner catalogue *write* — category,
  type, port template, status — had been refused with `Invalid mode.` since the module
  shipped, while `admin_load.php` listed the matching read modes so the panes loaded,
  rendered and offered their buttons. Two whitelists for one dispatch, and neither is
  wrong when read on its own. All twelve modes added; the three lists now reconcile.

**Chunk 2 — the meta-driven attribute form.** Migration 083 plus a form endpoint and the
renderer. This is the chunk that makes a *type* configurable.

- Static rows seeded into `planner.element_fields` (`element_name`, `type_key`,
  `status_key`, the two cable ends, `ownership`), so the whole form renders from meta —
  which auto-provisioning forces anyway: a category created in admin has no
  `editable_fields` JSON for anyone to hand-write.
- `planner.element_status_categories` with per-category `is_default`, and
  `fn_elements_derive()` rebuilt to read it (decision 28).
- **Blockages drawn since Release 1 hold `Planned`, and are re-statused rather than having
  the vocabulary widened** (found by this migration's own assertion, 2026-09-20). Until
  the rebuilt derive function, a missing status was filled from the one globally flagged
  row, which had no concept of a category — so every blockage drawn since Release 1 was
  stamped `Planned` because the default did not know what a blockage was. The bug and the
  rows it produced arrive together. Adding `Planned` to the blockage list would make a
  test artefact permanent and offer a meaningless choice forever; moving the rows to the
  category default loses nothing, because a value nobody selected carries no information.
  Same judgement migration 073 made for null-status blockages. The history trigger stays
  on for it — unlike 082's kind rename, this genuinely changes what a record says.
- Type and status options come from the registry filtered to the category, so a type added
  in admin is selectable on the map — which it is not today.
- Dynamic fields show and hide on the selected element's type.
- `editable_fields` emptied on all six layers; the hardcoded
  `<div id="…AttributeForm">` markup in `html_body_projectedit.php` goes with it.

**Chunk 2b — blockage colour by status, built and then reverted.** Migrations 084 and 085,
both applied. Recorded because the round trip is the useful part.

084 moved blockage colour from type to status and the label from status to type. It was
built on a report during chunk 2 testing that the layer was styling on two columns at once.
**It was not** — it styles on `type_id` alone, which is exactly what migration 076 decided
on 2026-09-20 and stated in its header: status was used by one layer, one layer styled by
status while every other is styled by type is the odd case rather than the useful one, and
the blockage label already carries the status. 085 reverts 084 in full and **076's decision
stands** (Dave, 2026-09-21).

Two things worth keeping from it. The revert is **complete** — `element_statuses.style_id`,
the second query in `planner_styles.php` and the client bucket all go, not just the layer
row, because a column and a style bucket wired to nothing look like a working feature and
are how a settled decision gets re-litigated later. And if status styling is ever wanted
again, the two **cannot share a style bucket**: both are keyed on a bare integer, so
`status_id` 3 would silently overwrite `type_id` 3 and a blockage would draw as whatever
type shared its number. That is in 085's header and in `map_layer_utils.js`.

**Chunk 2c — fields scope to categories, not types.** Migration 086 (decision 30).
Replaces `element_field_types` with `element_field_categories`, and **removes** the
per-type client machinery it made unnecessary: `type_ids` in the form payload,
`applyPlannerFieldScope()`, the wrapper's `data-type-ids`, the type-change listener, and
`sql/util_planner_scope_test_field.sql` — the live data already demonstrates category
scoping, since Contact Details is blockage-only and Fibre Allocation cable-only.
`plannerTypeDelete()` stops deleting scoping rows, because a type has none of its own.

**Chunk 3 — the two scoping UIs.** Migration 088 plus both admin surfaces.

> **There is no migration 087.** It was written to unscope `element_ref` and
> `customer_ref` from all six categories and **deleted before being applied** — that is
> admin configuration, not a schema change, and a migration must not make a choice an
> admin screen owns (Dave, 2026-09-21). The number is not reused: `migrate.php` globs and
> sorts filenames and tracks what it applied by name, so a gap is invisible to it, while
> renumbering 088 would break the immutability rule for no gain. The problem 087 aimed at
> is solved properly by chunk 3 normalising all-ticked to no rows, so the bad state cannot
> be stored rather than being corrected once.

- **"Offered on"** on the Statuses pane of `?do=admin_planner`, and **"Applies to"** in the
  field modal of `?do=admin_fields&module=planner`. Research item 4 / D3 calls the second
  "the one genuine extension the asset module requires"; without either, scoping is
  migration-only.
- **Both normalise all-ticked to no rows** (`plannerWriteCategoryScope()`), so the
  enumerated-all state cannot be stored — the thing that would silently exclude the next
  category anyone adds. An empty stored list renders as *all ticked*, because that is what
  it means; rendering it empty would invite the user to "fix" it into the broken form.
  `scope_present` is posted as a separate marker because jQuery drops an empty array,
  which would otherwise make "cleared" indistinguishable from "no such control".
- **Migration 088 moves the default status off the scoping row onto the category.**
  Building the screen is what exposed the conflict: `element_status_categories.is_default`
  cannot survive normalisation, because a status that applies everywhere has no row to
  carry it — "Planned applies everywhere AND is the cable default" was unstorable. The
  default was never a property of the status anyway; *a new blockage starts as Blocked* is
  a fact about blockages. Scope is now set on the status, the default on the category, and
  the category's dropdown offers **only statuses that category actually offers** — a
  combination no constraint can prevent, since absence-means-all leaves no row to
  reference.
- Narrowing a status's scope **clears any category default that just fell outside it**,
  or new elements would be stamped with a status their own form does not list.
- The global `element_statuses.is_default` survives as the **fallback** for a category with
  no default of its own, relabelled throughout so the two are not confused.

**Admin alignment landed with it** (Dave, 2026-09-21), all of it the planner having invented
its own conventions where the other admin screens already had one:

- `?do=admin_planner` **was not including `nav_admin.php`** at all, so the page was a dead
  end — the only way back to Module Management was out to the dashboard and in again. Two
  bugs fell out of fixing it: the **Planner Config** tab was nested inside the
  `admin_fields` permission block, invisible to exactly the user `admin_planner` exists
  for, and **Planner was missing from the Module Management dropdown**.
- Status **style picker** is now the shared swatch grid. The page bootstrap was flattening
  `statusStyleRegistry()` to `key => label`, discarding `css`/`ink` before the client could
  draw anything — hence a text dropdown where every other module shows colours.
- **Active is a checkbox** on statuses, types and categories, and the row-level power-off
  buttons are gone. `planner_status_toggle_active` was removed entirely.
- **Drag to reorder** on categories, types, port templates and statuses. They were the only
  hand-built HTML tables left in admin; they are Tabulator with `movableRows` now, and one
  `planner_reorder` mode serves all four. The Order column goes with it — the row position
  *is* the order, and a number beside it is a second answer that goes stale on the first
  drag. **Tabulator measures its container when it builds**, so panes loaded up front
  (categories and statuses, whose caches three modals read) must `redraw(true)` when their
  tab is first shown, or they render at zero width.
- **Statuses and Dashboard are not offered for planner** in Module Management, for opposite
  reasons: planner has no dashboard-able entity at all, while its statuses exist and live on
  Planner Config — an area whose whole content is a redirect is a dead end.
- The **`seeded` badge** is gone from Types. `is_seeded` was added for a reconciliation
  mechanism that was never built, nothing else reads it, and it stays `seeded` after a
  rename — so it stopped being true about the row it labelled. The column is kept.

**Chunk 4 — category auto-provisioning.** The four code changes that stood between an admin
and a usable category, removed:

- **Its map layer.** Saving a category inserts a `public.map_layers` row **and** a
  `public.map_layer_page_config` row for `projectedit` — `layer_editable`,
  `layer_allow_create`, `visible` and `snap_to_this` live only on the second, and a layer
  with no page_config row is invisible however correct its definition. `layer_type` comes
  from `geometry_kind`; the url carries `&filter={"element_kind":…,"category_key":…}`.
  New layers are **visible** by default, like every other planner layer: the person who
  created the category is the person about to draw on it, and defaulting off made a new
  category arrive switched off and looking like it had failed (Dave, 2026-09-21).
- **Its category on save.** `planner_save.php`'s seven-line `$layerCategories` array is
  gone; the category is read off the layer's own url filter, server-side rather than
  posted, since the category decides `element_kind`, the geometry rule and which fields
  apply. `PlanCablesAuto` is the one name still mapped by hand — it is not a `map_layers`
  row at all.
- **`$endFields` follows the kind.** `conductor` and `conduit` get A-End and B-End; it was
  three layer names, so a linear category created in admin got the fields on its form and
  had them silently discarded on save.
- **`PLANNER_SAVE_LAYERS` is derived** from `databaseSchema === 'planner'` — the same fact
  `map_layer_manager.php` gates on server-side, so the two cannot disagree. A name test
  would still be wrong whatever the prefix: `projectPIABlockages` does not start with
  `Plan` and `ProjectBoundary` does.
- **The attribute panel is created if absent**, and the six hardcoded `<div>`s in
  `html_body_projectedit.php` were **removed so the seeded layers go through the same
  path**. A creation path exercised only by new categories is one whose bugs only appear
  for new categories.

Renaming or deactivating a category updates its layer; deleting one removes it.

**Functional map layers are protected from deletion** (migration 089, Dave 2026-09-21).
`mapLayerDelete()` was an unconditional `DELETE` by id with no guard at all, so any layer
could be removed from `?do=admin_map_layers` — Project Boundary, Project Premises, the
wayleave coverage layers, every planner category. Pre-existing, but chunk 4 makes the
planner case worse: provisioning runs on category *insert* only, so deleting a planner
layer leaves a category that believes it has one, undrawable and with no route back.

`map_layers.is_system`, seeded from **`layer_type LIKE 'vector%'`**. Guarding by
`database_schema` was considered and rejected: **WMS layers use `database_schema` to build
their CQL filters**, so a reference layer published through GeoServer can legitimately
carry a module schema. `layer_type` is the real signal — a vector layer is one the
application draws from its own relation and usually writes back to, with a uid, an
attribute form and a save path; wms/xyz/tile are imagery that nothing edits. The Delete
button is disabled with the reason rather than hidden, Deactivate stays available, and
planner layers say to delete the category instead.

**Editing is guarded too, because deletion was never the only way to break one** (Dave,
2026-09-21). Repointing a layer's `url`, `database_table` or `uid` breaks its editor
exactly as completely and just as silently — a layer aimed at the wrong relation renders
nothing and raises nothing. On a system layer only **title, name, tooltip, z-index and
active** stay editable; everything that *defines* the layer is locked. Per-page setup is
deliberately untouched — it lives in `map_layer_page_config`, is written by
`map_layer_manager.php`, and turning a system layer on for another module is a legitimate
thing to want. The inputs are disabled with a note saying why, and `mapLayerUpdate()`
simply leaves the locked columns out of its UPDATE rather than rejecting them: the static-
field path rejects a changed identity column so a client bug surfaces, but there the locked
set is two columns and here it is eighteen, and no legitimate intent reaches that code
wanting to repoint a module's layer.

**`$endFields` has to move in the same change.** `planner_save.php` accepts the cable ends
only for three hardcoded layer names, while their field scoping is data an admin can edit.
Leave them enumerated against cable, duct and subduct until both move together: widening
the scoping alone would put two inputs on a new linear category's form that the save path
silently discards. Derive `$endFields` from the category's `element_kind` — ends are
meaningful on `conductor` and `conduit` — and the enumeration stops mattering.

**A new category has to be usable the moment it exists** (migration 090, Dave 2026-09-21).
Creating a conduit category and drawing on it before choosing a type failed with "no
Unspecified type to fall back on", and nothing on any screen configured that. Three
separate provisioning gaps, all the same shape — **a migration enumerated the categories
that existed the day it ran**:

| What | Enumerated by | Symptom on a new category |
|---|---|---|
| Fields | 083's static field seeding | No A/B ends on a conduit form (fixed with `plannerProvisionCategoryFields()`) |
| Statuses | 083 scoping every status to six categories | Empty status dropdown over a stored `Planned` |
| Starting type | 078 seeding `unspecified` into two named categories | First feature drawn is refused |

Absence-means-all does not save the status case, because **no status is absent** — 083
scoped all of them, so a new category matches none. Each provisioner therefore derives
from the new category's **siblings of the same kind**: what every existing conduit carries,
a new conduit carries. A kind with no siblings yet gets nothing, which is correct — there
is no shared answer, and inventing one is configuration, not derivation.

**The starting type became a column, `element_categories.default_type_id`**, the same move
088 made for the status. `planner_save.php` looked up the literal key `'unspecified'`, so
the fallback could not be repaired after that type was deleted and could not be pointed at
a real type — most conduit being 54mm duct is a reasonable thing for an admin to say.
Unlike the status there is **no global fallback behind it**: a type belongs to one
category, so NULL means the planner is asked to choose as they draw, which is a legitimate
setting and not an empty state. `admin_save.php` refuses to delete or deactivate whichever
type a category points at, on both the toggle and the modal's Active checkbox.

**A completely failed map save showed nothing at all** (Dave, 2026-09-21). The tally that
decides when to report — `savedCount + errorCount === total` — sat *inside* the
`result.success` branch of `#mapSaveEdits`, so the summary ran only if the last response to
arrive happened to be a good one, and never ran if every feature was refused. Not a planner
bug: any refusal from any editable layer hit it, and the planner only made it reachable by
being the first thing that refuses a save for a reason the user can fix. The check moved out
to a `finishSave()` called on every outcome, including the synchronous no-project-id path.

**The failure report is a toast, not an `alert()`.** The alert blocked the page, looked like
nothing else in the editor, and — drawn over the map — hid the feature it was describing.
It now uses `#errorToast`, held open until dismissed because it names something to act on,
with duplicate messages collapsed. **The success toast moved to match it** (Dave,
2026-09-21): it was `#toastMapEditsSaved`, an older header-style toast with its own title
bar and inline SVG, and giving one action two unrelated-looking answers is worse than
either on its own. Both outcomes now use the `#successToast` / `#errorToast` pair the PDF
export uses. `#toastMapEditsSaved` is left in the markup, unread — as it already was in
four other editor templates. The refused feature keeps
its `edited` flag, so choosing a type and pressing Save again is the whole recovery; nothing
is redrawn. The message itself says only **"Choose a type for this feature, then save
again"** — it reaches whoever is drawing, who cannot set a starting type and may not be able
to open the screen that would.

**Provisioning the type forces the category-delete rule to change.** A provisioned
Unspecified type would otherwise make every new category undeletable by both routes at
once — the category refuses because it holds a type, and the type refuses because it is the
category's starting type. The starting type is not counted as a type to clear first and is
deleted with the category, the same way its map layer is.

**Chunk 6 — map layer draw order** (Dave, 2026-09-21). Reference layers were covering
project layers. A single running order lets any layer end up above any other and nothing
holds the relationship once a layer is added or moved, so z-index becomes **three bands
keyed on `layer_category`** — base at 0, reference at 50, everything else at 200 — and
"project above reference above base" becomes a property of the category rather than
something the ordering has to keep getting right. The vocabulary is exactly those three,
fixed by the `layer-controls-*` containers every editor template carries; an unrecognised
category takes the project band, because drawn too high is visible and drawn under the
basemap is not. The gaps are well clear of the application's own overlays — `map_v5.js`
puts the create-project draw layer at 999 and GPS position at 5000 — and the `100`/`99`
values in `map_layer_utils.js` are *style* z-indexes, a separate space.

**Order is the drag handle; the Z-Index input is gone**, the same judgement the planner
catalogue made about Display order. **`z_index` had to leave `mapLayerFields()`, not just
the modal**: `mapLayerAdd()` and `mapLayerUpdate()` both walk that list with
`$_POST[$field] ?? null` and write every entry, so removing the input alone would have
written NULL over every layer's z-index on every save — the same trap that wiped the
planner's ordering. `mapLayerReorder()` is now its only writer, plus `mapLayerAdd()`
placing a new layer at the top of its band via a position of `-1`, which needs no count
and no knowledge of where the band ends.

**Top of the list draws on top** (Dave, 2026-09-21), matching `?do=map`'s panel and how a
layer list reads anywhere else, so within a band of *m* the first row gets `offset + m`.
**The client sends positions, never values or categories** — `layer_category` drives the
style lookup, so a drag that changed it would silently unstyle the layer — and **every**
layer is renumbered, not only the posted ones, because the header filters mean a drag can
post a subset.

**Pagination had to go, not grow.** A row cannot be dragged from page two to page one at
all, so no page size is the right one; a tall scrolling table instead.

**`?do=map` keeps its session-only reordering but shares the bands.** It said 0/100/200
while admin writes 0/50/200 — harmless while each stayed self-consistent, and exactly the
near-miss that stops being harmless when one changes. It still persists nothing: a drag
there is one person's view for one visit, and persisting it would let any map user rewrite
the draw order for everyone.

**A primary-key collision that was already live, found while checking the insert path.**
`plannerProvisionCategoryLayer()` assigned `layer_id` as `max(layer_id) + 1`, carrying a
comment from migration 037 saying the table has no sequence. It does —
`nextval('map_layers_id_seq')` is the column default and `mapLayerAdd()` has always relied
on it. **Migrations 037, 043 and 063 hand-assign it the same way**, and an explicit id does
not advance a sequence, so `nextval` was already well behind `max(layer_id)`.

It failed exactly as that predicts: adding a layer by hand succeeded, and creating a planner
category *immediately afterwards* failed (Dave, 2026-09-21). The first add consumed the last
free id; the next landed on one in use. **Late, elsewhere, and intermittent** — the
migration that causes the drift succeeds, and the breakage appears in a different screen as
a constraint name, only once the gap between `nextval` and `max` runs out.

A one-off resync would not hold: migrations are immutable, and the next one to ship a layer
starts the drift again. So `mapLayerSyncIdSequence()` runs before both application inserts.
`setval` to `max(layer_id)` is safe in either direction — everything above the highest id in
use is free — and it is guarded for a server whose column has no sequence at all.

**And `plannerProvisionCategoryLayer()` now reports the database's own message** rather than
flattening it to a sentence. This is an admin-only screen, the generic wording twice
reported a failure while saying nothing about it, and everything the function writes is
configuration the same user could type by hand — so a constraint name costs nothing and is
the difference between fixing it and going to find a log.

**Chunk 7 — A/B end markers on line elements** (Dave, 2026-09-21). A run drawn
right-to-left looks identical to one drawn left-to-right, so a planner connecting
`C ──── D` cannot tell that what they drew was `C (B)────(A) D`, and fills the A-End and
B-End dropdowns the wrong way round. The line still looks correct; only what it is
*connected to* is wrong, and nothing showed it.

**A is the first vertex, B is the last** — geometric, not a lookup. Nothing reads
`a_end_element_id`, and no view change or migration was needed. The first reading of this
had it backwards — that the referenced *elements* define the ends and the geometry must be
matched to them, which needs either a fragile client-side nearest-endpoint match or a
resolved column in the view. Recorded because the wrong version is the more obvious one and
it is expensive.

**Two static styles with `geometry` functions, not pre-built points.** OpenLayers
re-evaluates a style's `geometry` on every render, so the badges track the vertices as they
are dragged; built as fixed points, A would stay behind while the end it names moved away.
The same mechanism `editstyles` uses for its vertex highlighter. Returning null for
anything that is not a line is also how non-line features are excluded, so the application
sites need no branching.

**Shown only on the selected feature being edited**, which is not a small detail: it
removes the entire class of problems the prototype was built to test. Hub (eight cables
into one chamber), bundle (four parallel ducts) and dense (twenty runs) are all
many-markers-at-once cases, and only one feature is ever editable at a time (Dave,
2026-09-22). That is what makes the plainest rendering — a filled badge centred on the
vertex — the right one; the collision cases only exist for always-on variants.

**Scoped to where the form offers the ends**, a question about the category's field
scoping rather than about geometry: a line with no A-End and B-End would be labelled with
letters referring to nothing, and scoping the ends off a category takes the markers too.

**`concat`, never `push`.** `editstyles` is one array in `MapLayerUtils` that five files
hold a reference to. Geometry editing is project-only — `account_edit_v2.js`,
`opportunity_edit.js` and `stocklist_edit.js` alias it and never use it — but `?do=map`
does (`map_v5.js:2405`), with the same Select interaction and no planner concepts at all.

**It makes the mismatch visible, not impossible.** The dropdowns stay free. A Reverse
Direction action and a save-time warning are both in `docs/improvement-opportunities.md`;
today a backwards run can only be fixed by redrawing it.

**Chunk 8 — the end pickers order by distance** (Dave, 2026-09-22). `map_get_connection_points.php`
returned every equipment and structure in the project ordered by name, and both dropdowns
were filled from that one list. On a project of any size that is hundreds of entries, and
the one wanted is almost always whatever the line just drawn is touching. Each end now
offers the **nearest 10 within 500m, measured from its own end** — A from the first vertex,
B from the last, the same convention the badges draw, which is what makes the two agree.

Three things change shape, and none of them is the sort:

**A and B need different lists.** They were one jQuery selection filled in a single pass,
because one list served both. Distance from the A end and distance from the B end are
different questions.

**The list is per feature, not per page.** The old fill ran at load and after every save,
because it did not depend on what was selected. This one does, so it runs from
`refreshPlannerFormFromFeature()`. **The after-save full refill had to go**: two fills
racing for the same selects, with the loser decided by which request returned second — and
the per-feature one is the more correct after a save anyway, because a dragged line has
different things near its ends.

**The current value is always in the list, whatever its distance.** A `UNION` arm fetches
it unconditionally. Without it, selecting a line whose end is a kilometre away would render
an empty select over a real stored value and the next save would write the blank back — the
same rule the planner form already follows for a retired dropdown option.

Distance is metres with no transform, because `vw_elements.geom` is EPSG:27700. The cutoff
is **hard and has no escape**: a run legitimately terminating beyond 500m can only be set
before the geometry moves away, or it stays selectable only because it is already selected.
Whether that wants a *Show all* fallback is open.

**Chunk 4b — type dimensions** (decision 36, which supersedes 35). Two columns on
`element_types`, migration 091: `outer_diameter_mm` (what one occupies inside its parent)
and `inner_diameter_mm` (the bore — what it offers to its children, NULL on a conductor,
which has no inside). Millimetres in the column name, because a unit recorded anywhere else
is a unit that can be got wrong.

**They complete a model migration 072 already half-built.** That split conduit into
*discrete* capacity — a 6W Combi holds exactly six microducts — and *dimensional*, where a
12:8 Mono has one bore so what fits is a diameter question. It left `capacity` NULL on the
monos and said why. The dimensional half had nowhere to go, so it survives only inside the
type **name**: `12:8`, `7mm/3.5mm`. Legible to a person, invisible to every query.

**Nothing is seeded, deliberately** (Dave, 2026-09-22). Both monos state their dimensions in
their names and could have been back-filled. GeoLynx is multi-client and deploys are a
`git pull`, so `db/` runs against every client database: a migration seeding catalogue
values ships one client's data to all of them, and the next client spun up either inherits a
catalogue that is not theirs or clears it by hand before go-live. The column ships empty.

**Nothing reads them yet.** Phase 5's `vw_element_capacity` is where they earn their keep —
discrete count where `capacity` is set, cross-sectional area where it is not.

**What did NOT come with this, and why.** Cable drum length was considered and rejected
(Dave, 2026-09-22): it looks like a sibling of the diameters and is not. A cable drum length
is a *design constraint* — exceed it and you need a joint, a closure, a chamber and a splice
schedule entry — while duct stock length is a *quantity*, since 200 couplers is a materials
line and not a warning. One column cannot serve both, which is why no name fit. **Stock
length belongs to a BOM tab**, logged in `docs/improvement-opportunities.md` as wanting its
own plan doc, along with the line that decides where a future attribute goes: the type
registry has a **physical** facet the planner computes fit with, and a **supply** facet
(unit of issue, stock length, pack quantity, waste, cost) that only BOM needs. Max span —
pole-to-pole on aerial — is a third, structural limit; confirm aerial is in scope before
modelling it.

**Port group names stay a CHECK constraint** for now. They were folded into 35's scope as
the same fixed-vocabulary problem one table over; with 35 superseded they have no home, and
they are the one part of that idea still worth revisiting when a non-fibre sector arrives.

**Chunk 5 — snapping.** The A/B End leftover this chunk used to also carry **was already
delivered by 083**, which scoped `a_end_element_id` / `b_end_element_id` to cable, duct
*and* subduct; 086's assertion proves all three still reach them. Nothing to build.

**What snaps is configuration, not code.** `map_layer_page_config.snap_to_this` has existed
since the page-config table was built, is editable on `?do=admin_map_layers`, and has
reached the client as `snapToThis` all along (`map_layer_utils.js:1172`) — **nothing had
ever read it**. So this adds no new place to say what snaps; it makes the existing one mean
something. Per *page*, which is why it belongs on the page config: the same reference layer
can be worth snapping to in the project editor and not in the opportunity one, and a
layer's own row cannot express that.

**One `ol.interaction.Snap` per snappable source, not one over a feature collection.**
OpenLayers' Snap takes a single source, and a collection would mean mirroring every layer's
features into it and keeping that mirror in step with loads, edits and bbox refreshes. A
Snap given a source subscribes to it — so a layer still loading is not a problem, and
nothing re-runs when features arrive.

**Snap has to be added after the Draw or Modify it affects.** It works by adjusting the
pointer position those interactions then read, so one sitting earlier in the interaction
collection has already had its say. `applySnapInteractions()` removes and re-adds, and is
called at all four sites that add an interaction: Create, Edit's Select+Modify, the second
Modify, and auto-route's Draw. **Modifying snaps too** — dragging a cable end onto a
chamber is the same want as drawing it there, and a connection 0.3m out is no connection.
Auto-route's two ends feed pgRouting, so landing them on a real structure matters most
there.

**A new planner category defaults to NOT snapping** (Dave, 2026-09-21). Provisioning could
have turned it on, but which layers are worth snapping to is a judgement about how a
particular network is drawn, and guessing it per category would be inventing configuration.
The admin ticks what should snap — so the **toggle is disabled with its reason** until one
is ticked, because "no layer on this page snaps" is the state most people meet first and
would otherwise read as snapping being broken.

**The toggle is on by default and remembered per browser** (Dave, 2026-09-21), in
`localStorage`, not on the account: someone who turns snapping off is placing something
deliberately just clear of a feature, and having it come back after a reload would undo the
thing they turned it off to do. Every read is wrapped — `localStorage` throws rather than
returning null in a private window, and a map that would not load because a preference
could not be read is an absurd trade.

**A layer with no client-side geometry can never be snapped to**, whatever its config says,
so those layers are skipped when the interactions are built. Same test migration 089 uses
for `is_system`, and for the same reason: it is what distinguishes a layer the application
draws from its own relation from one it fetches as a picture.

**Five of the six page flags need geometry in the browser, not just Snap** (Dave,
2026-09-21). A wms/xyz/tile layer is an *image*: nothing in it can be selected, dragged,
created, deleted or snapped to, and **`focus_on_load` is worse than inert — it throws**.
`project_edit_v2.js:2010` fits from `olLayer.getSource().getExtent()`, which only
`ol.source.Vector` defines; a tile source never fires the `change` that would call it, but
an image source does. Only `visible` applies to an image, so `layer_editable`,
`layer_allow_create`, `layer_allow_delete`, `snap_to_this` and `focus_on_load` are all
disabled for a non-`vector%` layer, each with its own reason on the label.

**Turning something off is always allowed.** A box already ticked stays live even where it
cannot apply, because the alternative is a wrongly-configured layer nobody can correct from
this screen. The rule is that you cannot switch ON what cannot work. The state is recomputed
on every change, not only on render, or the box would stay live until the modal was
reopened. And the checkbox is **disabled, never cleared** — a disabled checkbox still
reports `.checked`, so `collectPageConfigs()` carries an existing value through untouched
and retyping a layer cannot silently discard configuration.

**Every reader and writer of `prospector`, and what each needs.** This is the complete list as
grepped on 2026-09-20 — the earlier version of this plan missed five of them.

| File | Reads / writes | Needed |
|---|---|---|
| `map_feature_save.php` | writes all six planner tables (137–204, 361–366) | Replaced by `planner_save.php`; the planner branches come out |
| `map_boundary_update.php` | `network_cables`, `network_points`, `network_routes` (114–126, 212–217) | Re-point at `planner.elements`; `network_routes` per audit report 8 |
| `map_get_v2.php` | `$schemaModuleMap` (116) | `'planner' => 'planner'` — Phase 2, but the edit is here |
| `map_get_connection_points.php` | `network_points` (67) | Re-point; the a-end/b-end picker widens to structures (decision 9) |
| `project_load.php` | `network_cables` (433), `vw_network_cable_export` (572) | Re-point at the rebuilt view |
| `project_fibre_allocation.php` | writes `network_cables.fibre_allocation` (94), reads `vw_network_cable_export` (136) | Re-point; replaced outright in Phase 6 |
| `project_auto_route.php` | `pia_blockages` join (261–263) | Re-point, plus the status fix and `routing_weight` (Phase 1 step 7, decision 13) |
| `network_topo_get.php` | `network_topo_point`, `network_topo_link` (68, 101) | Rebuilt views — Phase 7 |
| `network_topo_save.php` | `UPDATE network_points` for `sld_x`/`sld_y` (52) | Writes `planner.elements` — Phase 7 |
| `opportunity_process.php` | `network_points` (87, 126) | Re-point |
| `opportunity_manage.php` | nearby network points | Re-point |
| `opportunity_edit.js` | the opportunity network-points layer | Follows its view |

`serve_file.php:40` and `file_upload.php:45` both name `prospector.project_journal`, **a table
that has never existed** — `file_upload.php` already carries a comment saying so. Nothing to
migrate; note it here so the grep result is not mistaken for a missed dependency, and clean the
dead references up while in those files.

- `planner_load.php` / `planner_save.php` are the module's own endpoints; `planner_save.php` is
  the **only** writer of `planner.elements`, which is what contains the forgot-`WHERE
  element_kind` risk that decision 1 accepts.

### Phase 5 — Relationships, routing and capacity

- **Containment and attachment** via `parent_element_id` + `parent_relation`, with allowed
  parent categories enforced from the registry (a closure goes in a chamber; a chamber does not go
  in a closure).
- **Routing** — the ordered `element_routes` link. UI to pick the path a cable or subduct takes,
  including stretches with no container (direct buried or aerial), which are simply absent rows.
  **Add `container_position`** (decision 38) in the same migration that first writes route rows:
  which way of a 4-way duct, which microduct of a 7W combi. An integer, never a colour, and
  never decomposed. One nullable column now against revisiting every row later.

  **And `element_routes_history`, from the start** (Dave, 2026-09-22). Re-routing a cable is
  as significant a change as moving it, and every element change is already journalled. The
  reason to build it before anything writes a row rather than when tooling wants it: **a
  history table can be cleared, it cannot be populated after the fact.** Skipping it now
  makes every route written before it existed permanently unauditable. The trigger and table
  go in with the first write; a UI for reading it can wait indefinitely.
- **PIA capture.** `project_auto_route.php` already computes an ordered list of Openreach spans
  and currently discards it when the user saves the geometry to a layer. Capture it as
  `provenance = 'routed'` external routing links with a geometry snapshot. On a later geometry
  edit, **mark the links stale rather than deleting them**, and offer re-derive.
- **Derive-by-matching tool** for hand-drawn or edited geometry: buffer against the reference
  network, write `provenance = 'derived'` links with a confidence score. This is the automated
  version of the hand-matching exercise that made this a near-impossible task before.
- **Capacity** from `vw_element_capacity`: type capacity against routing-link count, so a 4-way
  duct with three subducts shows one way spare. Reported, not enforced — an over-full duct is a
  real-world condition that must be recordable. Its denominator is the port template from Phase 1
  step 4, which is why the two ambiguous seed values there are worth settling properly.
- **Routing weight into auto-route — NOT WANTED** (Dave, 2026-09-22). ~~`element_types.routing_weight`
  as the per-type default~~. A blockage is a planning and survey decision — clear it or avoid
  it — and the weighting exists only to steer auto-route over PIA. The per-run
  `routePIABlockageAvoidance` slider already says how hard to try; a per-type weight on top
  of it is complication without a question it answers. **Dropped rather than deferred.**

  The **Phase 1 step 7 fix has already landed** and is the half of this bullet that mattered:
  the blockage join reads `b.status_key NOT IN ('Cleared','Resolved')` and the `'Blocked'`
  string literal is gone.

  What this leaves: `element_types.routing_weight` is a column nothing reads, with a slider
  in the Type modal configuring it. Logged in `docs/improvement-opportunities.md` — a control
  that changes nothing is the thing the Map Layers work just went and disabled elsewhere.

### Phase 6 step 6c — the connection canvas

**Design 1 from the `www/test.php` prototype, settled 2026-09-26.** Two columns with lines
drawn between them, because it is the only one of the three that shows the *shape* of an
allocation — a block reads as a block, a crossed pair reads as crossed, a gap is a hole you
can see. Needs a workspace, so it is a wide modal and not the 260px attribute panel.

Settled behaviour, all of it tested in the prototype:

- **Click then click, never drag.** Drag lost on accuracy and then lost outright, because it
  cannot express a range. A click destroys nothing; disconnect is an explicit action on a
  selection, which makes it work on twelve fibres for free.
- **Shift-click extends a range** from the last click.
- **Colour by destination**, six colours then grey. A 24F splitting into two 12F cables gets
  two colours, so each bundle reads as a bundle and a line crossing between colours stands
  out as the anomaly it usually is. Chosen to survive common colour vision deficiencies.
- **Hover focus** dims everything not on the hovered path.
- **Dashed = planned**, solid = made.
- **Check before allocating.** Conflicts are named, and compaction is offered by name rather
  than as a count.
- **Unsaved work is marked**, and the buttons live in an always-visible bar — the prototype's
  appear-on-change version was rejected for moving under the pointer.

**Where it opens from.** Keyed on the *connection point* — any element whose type has
`can_be_cable_end` (migration 105), so a chamber, a cabinet, a closure or a PBO. Two entries,
one implementation: from a structure it lists what is present there, and from a cable's Ports
tab an A/B end tab strip resolves to the same pair. There is no disambiguation step before
the modal in either case; the selector lives inside it.

**The right column follows 105's rule** — closed for equipment (that unit's own ports only),
open for a structure (anything housed in or terminated at it). A cable drawn into the location
with no A/B end set appears greyed, reading *ends here but not linked*, with a one-click fix:
a trigger refusal becomes something visible.

**The Ports tab's CONNECTIONS become read-only, and that is its own chunk at the end** (6c-5,
Dave 2026-09-27). The scope is the connections shown against each port, not the ports: a list
sorted by end then fibre that says what a port is joined to and offers no way to change it.
The canvas becomes the only place a connection is made or broken, so there is one way to do it
rather than two that can disagree. Doing it last means every earlier chunk stays testable
against a working editor.

**Per-element port editing STAYS in the Ports tab** — add, rename and delete are untouched.
6a made ports editable per element deliberately, because reality diverges from the catalogue,
and nothing about the canvas changes that. An earlier draft of this section read the
instruction as *no editing at all* and went looking for somewhere to move port editing to;
there is nowhere to move it and no reason to (Dave corrected it the same day).

**A new `batch` mode in `planner_connections_save.php`, all-or-nothing** — not a change to
`connect`, which is what an earlier draft of this section said. `connect` takes two ranges and
commits each pair in its own SAVEPOINT, reporting partial success; that is *right* for a range
typed blind, where eleven of twelve joined beats none. The canvas is the opposite case: it has
already named every conflict by port label and offered compaction as an explicit choice, so a
partial commit would mean the planner approved one thing and got another. Two different
callers wanting two different failure semantics is not a contradiction to be resolved by
picking one — and `connect` loses its only caller in 6c-5 anyway, so changing it and then
deleting it would be wasted work.

`batch` takes the additions **and** the removals in one transaction, applying removals first
so replacing a connection on the same port does not collide with the row being replaced.
Undoing a stored join therefore queues it rather than deleting it on the spot, which is what
makes Cancel abandon a removal as readily as an addition, and makes "undo this one, put that
one there" a single save.

**6c-5 removes `pair` and `connect` with the Ports tab's controls**, leaving `batch`,
`disconnect` and `kind`.

Build order, each chunk with its own tests: **6c-1** the modal shell and both entry points ·
**6c-2** the canvas — columns, lines, click-to-join, colour, hover · **6c-3** ranges, the
pre-check and compaction · **6c-4** saving, all-or-nothing, and the unsaved marking · **6c-5**
the Ports tab's connections become read-only.

### Phase 6 — Connections, ports and structured fibre allocation

- **Port generation.** Creating an element generates its ports from the type's template. Ports are
  editable afterwards, because reality diverges from the catalogue.
- **Connections** — a header (`from_element`, `to_element`, kind: splice / patch / connectorised /
  terminated / spare) with **one detail row per port pair**. The header drives the schematic; the
  details drive the splice schedule.
- **UI does the range work, storage does not.** "Allocate all", "1–12 → 1–12", "next free" and
  drag-select each expand into individual port pairs, so a non-contiguous allocation into splitter
  outputs 1, 2, 5, 7 is as expressible as a tidy block.
- **Replaces** `network_cables.fibre_allocation` and
  `network_points.point_fibre_allocation_details`. The migrated free text is retained on the
  element as a dynamic field for reference until the structured data is entered.
- **Splice schedule output** per closure or cabinet — the field deliverable.
- MVP quality is acceptable here: no client uses the current fibre allocation tab.

### Phase 7 — Schematic rebuild, cutover and cleanup

> **The schematic half is now its own plan doc, and it is COMPLETE (2026-09-29):
> `docs/2026-09-28-planner-topology-rebuild.md`, migrations 107–110.** Read that rather than
> what follows, which is kept only because the cutover and cleanup items below are still open.
>
> What follows predates the port and connection
> model and is wrong about the starting point in two ways. It calls the work a rebuild of
> `prospector.network_topo_point` / `network_topo_link`, which migrations 074 and 079 already
> moved to `planner.vw_topo_point` / `vw_topo_link` — the views exist and work. And it names the
> `'L0'` literal as something to replace, which 079 already did via `is_network_head`.
>
> What is actually left is narrower: nodes are equipment-only so no structure appears; an edge is
> lost entirely if either cable end is a structure, silently; and the Cytoscape classes are
> string-mangled from `type_key`. Scope settled with Dave 2026-09-28 as **element-grained** —
> fibre trace and the capacity SLD are features in their own right and are logged in
> `docs/improvement-opportunities.md`.
>
> The rest of this section — the fibre allocation tab, cutover and cleanup — still stands. The
> tab was done by 6d.


**The schematic is a rebuild, not a relocation** (decision 14). An earlier version of this plan
called it a relocation on the grounds that it reads `a_end`/`b_end`, which are unchanged. It does
not: it reads two views that do not survive a type registry.

- **`prospector.network_topo_point`** branches on `point_type = 'L0'` for the head-end node, and
  derives the Cytoscape class name by string-mangling `point_type` through four nested
  `replace()` calls (`-`, `:`, `(`, `)` to `_`), falling back to `UNK`. Rebuild over
  `planner.elements`: the class comes from `type_key`, and the `'L0'` literal becomes
  `element_types.is_network_head`. **The Cytoscape stylesheet's class names in
  `project_edit_v2.js` must be re-mapped in the same change** — they are currently the mangled
  free-text values, and an unmatched class renders an unstyled node rather than throwing.
- **`prospector.network_topo_link`** inner-joins *both* cable ends to `network_points`, so a cable
  terminating anywhere else is silently absent from the schematic. Rebuild it joining
  `planner.elements`, which under decision 9 means **cables terminating at a structure now appear
  where they never did**. That is the intended gain, not a regression — worth saying out loud
  before someone reports it as one. Its `CASE` filter is also redundant against its own inner
  join; drop it rather than port it.
- **Node positions** come from `planner.elements.sld_x` / `sld_y`, written by the rebuilt
  `network_topo_save.php`. They are columns rather than dynamic fields because they are written on
  every node drag.
- Three dead variants of the function — `networkTopologyOld()`, `networkTopologyv2()`,
  `networkTopologyPrev()` (`project_edit_v2.js:3552`, `3718`, `3992`) — go with this change.
  Only `networkTopology()` at `4583` is live.
- Move the fibre allocation tab to the planner module in its new structured form.
- Dev testing, then hard cutover. No parallel running.
- **Cleanup is a separate follow-up job, not a step here** (decision 16). The source tables were
  renamed in place at Phase 1 step 9 and the `prospector` schema is still standing. Once the
  module is confirmed complete, a follow-up reviews the whole schema — the renamed planner
  tables, the four `*_migrated_20260804` opportunity tables and `prospector.estimates` — and
  backs up or drops it.

## Seams left for assets

Nothing here builds the assets module, but these exist so that it is a copy rather than a
translation:

- **Shared registries.** Assets get their own tables but reference the *same* categories, types,
  port templates and styles. A chamber type is one row used by both.
- **Ownership on every element**, so promotion can be restricted to owned elements and PIA can
  never become an owned asset.
- **A complete relationship graph** — containment, attachment, routing, connections — that
  promotion copies rather than reconstructs.
- **The routing link is already polymorphic** across our elements and external references, which
  is the same shape needed for planned-vs-asset containers when brownfield reuse arrives.
- **Type protection.** A type in use cannot be deleted, only deactivated — the dynamic-field rule,
  applied to the registry, so promoted assets keep resolving their type forever.
- **The promotion source** (added 2026-10-07, `docs/2026-10-02-project-milestones.md`):
  `planner.milestone_elements` of an as-built milestone — one flat row per element with the
  registry names captured beside the ids and routes, ports and field values as jsonb — is what
  promotion reads, idempotent on `element_id`. `planner.plan_locked_by()` is the single
  definition of a locked plan; the assets migration adds "or promoted" to it and nothing else
  moves.

## Testing checklist
`Test in batches of 5-10 items, organised and with clear instructions.`

1. `sql/planner_preflight_audit.sql` runs read-only against the live database and returns all ten reports.
2. Audit output reviewed, and any orphan references and null type values resolved before migrations are written.
3. Audit answers whether `prospector.network_routes` exists and holds rows, and the migrate-or-delete decision is recorded.
4. Audit reports `pia_blockages` counts by `blockage_status`, so the size of the auto-route fix is known before it lands.
5. Audit confirms whether `prospector.vw_cabinet_locations` and `prospector.estimates` have any live consumer in `public.map_layers` or GeoServer.
6. Audit reports, per linear table, how many rows have `geom_user`, how many have `geom`, and how many differ — so the single-geometry decision is made on evidence.
7. Migration 066+ applies cleanly on a restored baseline plus existing migrations.
8. Geometry trigger rejects a point geometry saved against a line category.
9. SRID check rejects a geometry in any SRID other than 27700.
10. Each per-kind view reports its geometry type and SRID correctly in `geometry_columns`.
11. `EXPLAIN` confirms a `project_id` filter through a view reaches the base table and uses the partial GiST index.
12. `planner.elements_history` rows are written by the triggers for all five element kinds, not just cables.
13. Journal records create, update and soft delete for every element kind.
14. A soft-deleted element disappears from every view and its journal and history still resolve.
15. Type registry contains every value the audit found, not only those in the seed export.
16. Registry `type_key` and `type_name` match `reference-data/project_map_elements_types_seed.json` exactly for all six layers, with equipment at 11 types.
17. No row anywhere still carries `CMJ(1:4-1:8)`; the 2026-09-20 correction to `CMJ(1:8)` holds in every environment being migrated.
18. Migration reports and fails on any distinct type value in live data not matched by the registry.
19. Every `prospector` row lands in `planner.elements` with matching counts per source table.
20. `a_end`/`b_end` and parent references resolve through the id map with no orphans.
21. Subduct rows land as kind `duct`, category *Subduct*, and remain visually distinct on the map.
22. `pia_blockages` rows land as kind `area` with the routing weight applied.
23. Non-load-bearing columns appear as dynamic field values, with original values intact.
24. `sld_x` and `sld_y` land as columns on `planner.elements`, not as dynamic fields.
25. Cable, duct and subduct geometry arrives from `geom_user` as `elements.geom`, and the original `geom` column's contents are in the audit output before it is dropped.
26. Port templates exist only for fibre-bearing types; no duct or subduct type has one, and their capacities come from `capacity` / `capacity_unit` instead.
27. `network_cables` history is carried into `planner.elements_history` and the original table is retained.
28. Each source table is renamed in place to `*_migrated_<date>`, the `prospector` schema is still present, and the `*_migrated_20260804` tables and `prospector.estimates` are untouched.
29. Dependent views (`vw_network_cable_export`, the two topo views, `vw_cabinet_locations` if live) are recreated against `planner.elements` rather than left pointing at renamed tables.
30. Planner module can be disabled, and the planner tabs and endpoints disappear together.
31. A user with projects write but no planner permission cannot load or save planner elements.
32. A user with projects write and planner read sees the planner layers, has no drawing or editing tools, and is refused on save — the project-manager role.
33. A user with planner write but read-only item permission on the project cannot save.
34. Saving against a deleted project is refused with the standard generic message.
35. `map_get_v2.php` serves the planner layers to a user holding planner read.
36. A user holding opportunities but not planner can no longer read the planner layers.
37. A user with planner write but no `admin_planner` cannot create or edit element categories, types, port templates or statuses.
38. A user with `admin_planner` but no `planner` can configure the catalogue and cannot draw or edit elements.
39. A user with `admin_fields` but no `admin_planner` can manage planner dynamic fields and cannot reach Planner Configuration.
40. Granting `admin_planner` actually changes behaviour — it is checked in `routes.php` and in every planner mode in `$modePermissions`, not merely listed in `MODULES`.
41. After migration, a role that had projects write retains planner access without manual role editing.
42. The `?do=admin` matrix renders planner with its label rather than blank.
43. The matrix's Statuses cell for planner points at Planner Configuration instead of reading as not applicable.
44. Admin can create a category, a type and a port template, and a new element of that type generates its ports.
45. A type in use cannot be deleted, only deactivated.
46. Statuses can be added, reordered and retired; the default status can be neither deleted nor deactivated.
47. Dynamic field scoped to one type appears only on that type's attribute form.
48. The other four modules' field meta behaviour is unchanged after the type-scoping extension.
49. All six planner layer rows point at the new views and render in the project editor.
50. Style resolution applies the type base, then the status partial override, without restating unchanged properties.
51. GeoServer publishes each view with stable feature ids.
52. Opportunity module still resolves nearby network points after the schema move.
53. Drawing produces point, line or polygon according to the category's `geometry_kind`, and `planner_save.php` is the only writer.
54. Attribute forms render from the field meta system, with no `editable_fields` JSON left in the planner layer rows.
55. Snapping works against existing elements and against reference layers.
56. Auto-route still produces a geometry that saves to the chosen layer.
57. The connection-point picker offers structures as well as equipment.
58. Containment rejects a parent whose category is not permitted to contain the child.
59. A cable terminates at a structure with no equipment present, and the schematic draws it.
60. Routing link records an ordered path with a gap where the cable is direct buried.
61. Auto-route over PIA captures ordered external routing links with `provenance = 'routed'` and geometry snapshots.
62. Editing an auto-routed geometry marks its routing links stale rather than deleting them.
63. Derive-by-matching produces `provenance = 'derived'` links with a confidence on hand-drawn geometry.
64. Duct capacity view reports spare ways correctly with a filter on one project.
65. An over-full duct is recordable and reported, not blocked.
66. Auto-route uses `routing_weight` as its default and the per-run avoidance control still overrides it.
67. An Active blockage is avoided by auto-route and a Resolved one is not.
68. Port allocation into splitter outputs 1, 2, 5, 7 saves and reloads correctly.
69. Allocate-all on a 96F cable expands to 96 port pairs.
70. Splice schedule output for a closure matches the port pairs entered.
71. Migrated free-text fibre allocation is visible as a dynamic field after Phase 6.
72. The rebuilt topo views render the schematic as it looks today, with Cytoscape classes keyed on `type_key`.
73. The head-end node is identified by `element_types.is_network_head` rather than a `point_type = 'L0'` literal.
74. Node positions survive a drag and reload through `planner.elements.sld_x` / `sld_y`.
75. The three dead `networkTopology` variants are removed and the live one is unaffected.

### Release 2, Phase 4 chunk 1 — one view and kinds as mechanics

76. Migration 082 applies cleanly on a database at 081.
77. All six planner layers render in the project editor with the same features as before.
78. The Duct layer shows only duct and the SubDuct layer only subduct — the filter is reaching the query, not being discarded.
79. Planner Configuration has an Element Kinds pane, with no Add button and no Delete button on any row.
80. Renaming a kind's label changes what the Categories table shows and what the Add-category dropdown offers.
81. Drawing, editing, moving and deleting an element still works on every planner layer.
82. The cable A-End and B-End dropdowns still list the project's equipment.
83. The project header's Cable Meters figure is unchanged.
84. The fibre allocation tab still lists the project's cables.
85. The schematic still renders, with the same nodes and links.
86. Auto-route still avoids blockages and still produces a saveable geometry.
87. An opportunity with a linked project still resolves its nearby network points.
88. A user with projects write and no planner permission still sees no planner layers.
89. `EXPLAIN (ANALYZE, BUFFERS)` on a project-filtered layer read uses `elements_project_kind_category_idx`.

### Release 2, Phase 4 chunk 2 — the meta-driven attribute form

90. Migration 083 applies cleanly on a database at 082.
91. Every planner layer's attribute panel renders with its fields, in order, with Name and Type at the top.
92. A type added in Planner Configuration appears in that layer's Type dropdown without any other change.
93. A blockage now shows Address, Contact Details, Requested Access Date and Status Date, with the values migrated out of `pia_blockages` in them.
94. Equipment, Structures, Duct and SubDuct now offer a Status, and Cables still offer only Planned / Surveyed / Built.
95. A blockage offers only Blocked / No Access / Cleared / Resolved.
96. Duct and SubDuct now offer A-End and B-End, populated with the project's equipment, and they save.
97. Changing the Type dropdown shows and hides that type's fields immediately, without saving.
98. Editing and saving an element keeps every value — static and dynamic — after a page reload.
99. A newly drawn element of each layer saves, takes its category's default status, and re-opens with what was entered.
100. A new blockage defaults to Blocked rather than Planned.
101. Clearing a field and saving removes the value rather than storing a blank.
102. Selecting a cable, changing only the A-End, and saving leaves the other fields untouched.
103. The Cable Meters figure, the fibre allocation tab, the schematic and auto-route all still work.
104. Type, Status and Ownership cannot be set back to empty — their blank choice is visible but not selectable.
105. Name, A-End and B-End can still be emptied, and saving clears them.
106. A new element left with no Status chosen still saves, and comes back with its category's default.

### Release 2, Phase 4 chunk 2b — 084 reverted, and the chunk 2 follow-ups

107. Migration 085 applies cleanly on a database at 084.
108. Blockages are coloured by type again — Public red, Private black, Unspecified grey — whatever their status.
109. Blockage labels read the status again, which is the only place it appears on the map.
110. All six planner layers style through the `planner` bucket on `type_id`.
111. The cable ends dropdown offers structures as well as equipment, with the kind shown.
112. Duct and SubDuct A-End / B-End save against a structure and reload correctly.
113. A newly drawn element shows its saved status in the form immediately, with no page reload.
114. Changing an element's Type restyles it on the map immediately, before saving.

### Release 2, Phase 4 chunk 2c — fields scope to categories

115. Migration 086 applies cleanly on a database at 085.
116. Each category's form shows the same fields for every type in it, and adding a type in admin needs no field configuration.
117. A newly added type immediately carries its category's fields — Reference, Customer ID and the rest.
118. Category-only fields still appear only where they belong: Contact Details on blockages, Fibre Allocation on cables, Fibre Allocation Details on equipment.
119. The cable ends still appear on Cables, Duct and SubDuct, and nowhere else.
120. Existing attribute values are all still present and editable after the migration.

### Release 2, Phase 4 chunk 3 — the scoping UIs

121. Migration 088 applies cleanly on a database at 086.
122. `?do=admin_planner` → Statuses: the table has Offered on and Starts as columns, and Blocked reads `Blockage` / `Blockage`.
123. Statuses → edit Planned: the Offered on list has Blockage unticked and the other five ticked.
124. Statuses → edit Built → tick every category → Save; reopen it: still every box ticked, and `planner.element_status_categories` holds no rows for it.
125. `?do=admin_planner` → Categories: the table has a Default status column reading Blocked for Blockage and Planned for the rest.
126. Categories → edit Cables: the Default status dropdown lists only Planned, Surveyed and Built.
127. Categories → edit Cables → set Default status to Surveyed → Save; draw a new cable in `?do=projectedit`: it lands on Surveyed.
128. `?do=admin_fields&module=planner` → Fields → edit Fibre Allocation: the Applies To panel shows Cables ticked only.
129. `?do=admin_fields&module=projects` → edit any field: no Applies To panel.
130. Fields → edit Address → untick every category → Save; reopen: every box ticked, and `planner.element_field_categories` holds no rows for it.
131. After 130, `?do=projectedit` → Plan Cables → select a cable: Address now appears on the cable form.

### Release 2, Phase 4 chunk 4 — a new category is usable on creation ✅ *(tested 2026-09-21)*

132. Migration 090 applies cleanly on a database at 089, and its NOTICE names any category with no starting type.
133. `SELECT category_key, default_type_id FROM planner.element_categories` — every seeded category has one.
134. `?do=admin_planner` → Categories: the table has a Starting type column reading `Unspecified` for all six.
135. Categories → Add a `conduit` / `line` category → reopen it: Starting type is `Unspecified` and Default status is `Planned`.
136. `?do=projectedit` → draw on the new layer and save without touching the form: it saves, and the panel shows `Unspecified` and `Planned`.
137. Types → the new category's `Unspecified` row carries a `starting type` badge and its Delete button is disabled.
138. Types → edit that row → untick Active → Save: refused, naming the category.
139. Categories → edit the new category → set Starting type to `No starting type` → Save; draw another feature without choosing a type → Save Map Edits: a red toast says "Choose a type for this feature, then save again", and stays until closed.
139a. After 139, choose a type on that feature's form → Save Map Edits: it saves, with no redraw.
139b. Draw two features on the no-starting-type layer and one on a working layer → Save Map Edits: the toast reads "Saved 1, could not save 2" with the sentence appearing once, not twice.
139c. A clean save now shows the green `#successToast` — the same shape and corner as the red one, no title bar — reading "Saved N feature(s)."
140. After 139, Types → the `Unspecified` row can now be deactivated and deleted.
141. Categories → Add a second throwaway category, then delete it straight away without removing anything: it goes, its `Unspecified` type goes with it, and no `public.map_layers` row remains for it.

### Release 2, Phase 4 chunk 4b — type dimensions ✅ *(tested 2026-09-22)*

184. Migration 091 applies cleanly on a database at 090.
185. `psql`: `\d planner.element_types` — `outer_diameter_mm` and `inner_diameter_mm` are `numeric(6,2)`, both nullable, and the two CHECK constraints are listed.
186. `psql`: `SELECT type_key, outer_diameter_mm, inner_diameter_mm FROM planner.element_types ORDER BY 1;` — every row is NULL. Nothing was seeded.
187. `?do=admin_planner` → Types → edit `12:8 Mono`: Outer diameter and Inner diameter inputs appear, both blank.
188. Set outer 12, inner 8 → Save → reopen: both held.
189. Edit a cable type (`144F`): Outer diameter appears, Inner diameter does **not**.
190. Edit a structure or equipment type: neither appears.
191. Edit `12:8 Mono` → set outer 8, inner 12 → Save: refused (the bore cannot exceed the outside).
192. Set outer 7, inner 3.5 on `7mm/3.5mm` → Save → reopen: the decimal survived as 3.5, not 3.
193. Clear both on any type → Save → reopen: both blank, and the type still works everywhere it did before.
194. Draw and save a duct in `?do=projectedit`: unaffected — nothing reads these yet.

### Release 2, Phase 4 chunk 8 — end pickers ordered by distance ✅ *(tested 2026-09-22)*

174. Select a cable: A-End and B-End are different lists, nearest first, each entry showing metres.
175. The top of each matches what its badge is sitting on, and the metres agree with the scale bar.
176. Each list is at most 10 entries plus *No connection*.
177. Drag an end mid-edit: that end's list updates without leaving the feature; the other end's does not.
178. Set an A-End, save, re-select: still set and still listed.
179. Drag a set end beyond 500m: it stays selected and stays listed with its real distance, and *— nothing within 500m —* appears alongside it.
180. Drag it back among neighbours: the notice goes and the neighbours return.
181. A cable in open ground with nothing set: *No connection* plus the notice, nothing else.
182. Select a structure: no console errors.
183. `?do=map` and the other editors are unaffected — `data_static` is unchanged.

### Release 2, Phase 4 chunk 7 — A/B end markers ✅ *(tested 2026-09-22)*

165. `?do=projectedit` → select an existing cable and edit it: a dark **A** badge on the first vertex, **B** on the last.
166. Drag the A-end vertex elsewhere: the badge follows it.
167. Deselect: both badges go.
168. Edit a structure or the project boundary: no badges.
169. Draw a new cable: no badges while drawing; select it afterwards and they appear.
170. Draw right-to-left: **A** is on the end you started on. Draw left-to-right: **A** is on the left.
171. The A-End and B-End dropdowns are unchanged and still freely settable — the markers do not constrain them.
172. `?do=admin_fields&module=planner` → scope A-End off the Cables category → reload and edit a cable: no badges.
173. `?do=map` → edit a geometry: no A/B badges.

### Release 2, Phase 4 chunk 6 — map layer draw order ✅ *(tested 2026-09-21)*

153. `?do=admin_map_layers`: the table has no pager, scrolls, has a drag handle, and carries no Z or ID column.
154. Rows are in draw order — project layers first, then reference, then base — each with a coloured left edge matching the legend above the table.
155. `psql`: `SELECT layer_category, layer_title, z_index FROM public.map_layers ORDER BY z_index DESC;` — base below 50, reference 51–199, everything else 201 and up.
156. Drag a reference layer to the very top of the reference rows in one movement → the page reloads and it is first among them.
157. Reload `?do=projectedit`: a project layer that a reference layer used to cover now draws on top of it.
158. Edit any layer: the Display tab has no Z-Index input, and saving leaves its Z unchanged (check it in the table afterwards).
159. Edit a **system** layer: the note says to drag it in the list to change where it draws, and dragging one works.
160. Add Layer with category `reference`: it lands at the top of the reference group, not above the project layers.
161. `?do=admin_planner` → add a category: its new layer appears at the top of the project group with a Z above 200.
162. After 161, Add Layer by hand: it saves rather than failing on a duplicate id.
163. Type a filter into the Title header filter, drag a row, clear the filter: the hidden layers have kept their relative order.
164. `?do=map`: dragging a reference layer in the panel still reorders it visually, and reloading restores the admin order.

### Release 2, Phase 4 chunk 5 — snapping ✅ *(tested 2026-09-21)*

*143a and 5 below could not be exercised: no wms/xyz layer on this system carries any of
the five vector-only flags, so the already-ticked-stays-live path has no data to run
against. It only matters for a layer that is already misconfigured.*

142. `?do=projectedit`: the Snapping button sits under Attributes, disabled, and hovering it says no layer on this page is set to snap to.
143. `?do=admin_map_layers` → edit a WMS or XYZ layer → Pages tab: Editable, Allow Create, Allow Delete, Snap and Focus on Load are all greyed with their own reason on hover; Visible is not.
143a. Same modal, if any of those five is already ticked: that one stays live so it can be turned off — and untick it, and it greys out.
143b. Add Layer → Pages tab → tick a page, then type `wms` into Layer Type on the Basic tab and return: the five have greyed without the modal being reopened.
144. Same modal, a `vector*` layer: all six ticks are available.
145. Map Layers → tick Snap on Plan Structures for the Project Editor page → Save → reload `?do=projectedit`: the Snapping button is now enabled and pressed-in.
146. Create New → a cable: drawing within ~10px of a structure lands the vertex exactly on it.
147. Press Snapping to turn it off, draw again near the same structure: the vertex lands where the cursor was.
148. Reload the page: Snapping is still off.
149. Press it back on, then Edit an existing cable and drag an end near a structure: it snaps.
150. Auto Route: picking the two ends snaps them onto structures.
151. Tick Snap on a second layer → reload: hovering the button reports two layers.
152. Untick Snap everywhere → reload: the button is disabled again, and drawing snaps to nothing.

## Outstanding — GeoServer

**The only Release 1 task that cannot be done from a migration**, and the only one that
fails *quietly*: a GeoServer layer whose source relation has been renamed returns an empty
layer, not an error. Everything else in this build fails loudly by design (decision 16).

**Deferred until Release 2 is delivered** (Dave, 2026-09-20), so these three layers are
knowingly serving empty until then.

Three published layers read tables that migration 074 renamed to
`*_migrated_20260920`. Migration 082 changed what they become: all three now read the
**same** view and are separated by a CQL filter, rather than three typed views.

| GeoServer layer | Was | Now reads | CQL |
|---|---|---|---|
| `GeoLynx:meet_me_points` | `prospector.network_points` | `planner.vw_elements` | `element_kind = 'equipment'` |
| `GeoLynx:pia_blockages` | `prospector.pia_blockages` | `planner.vw_elements` | `element_kind = 'area'` |
| `GeoLynx:network_cables` | `prospector.network_cables` | `planner.vw_elements` | `element_kind = 'conductor'` |

Their `public.map_layers` rows (layer_ids 15, 24, 27) were re-pointed by migration 077 and
again by 082 — only the GeoServer side is left.

**The geometry column is `geometry(Geometry, 27700)`** — open type, declared SRID. The SRS
resolves from `geometry_columns` without being set by hand; the geometry *type* does not,
because one view serves points, lines and polygons. Declare it per layer, or use a
GeoServer SQL view that does.

Per layer:

1. Re-point the feature type at the new view in the same store.
2. **Set the feature id.** The views have no primary key, so GeoServer needs either a
   `gt_pk_metadata` row naming `element_id` or a GeoServer SQL view declaring it. Without
   it, WFS and feature identification are unreliable.
3. **Check the SLD.** The old styles filter on `point_type`, `blockage_status` and
   `cable_type`. Those columns do not exist on the new views — they are `type_key` and
   `status_key`. An SLD filtering on a missing column renders nothing rather than
   erroring.
4. Confirm the geometry column is `geom` and the declared SRID is 27700. Cables, duct and
   subduct previously served `geom_user`, which became `geom` (decision 18).

Worth knowing while doing it: **blockage colour now comes from type, not status**
(migration 076). If the `pia_blockages` SLD colours by state it will need rewriting
against `type_key`, or re-pointing at `status_key` if colouring by state is still wanted
on that layer specifically.

## Risks

- **Cutover order and what deployment does not cover.** The mechanism is solved — `git pull` plus
  `php db/migrate.php --apply`, and the migration creates `planner` alongside `prospector` so
  there is no broken window provided the **migration runs before the pull**. Three things are
  outside that: **GeoServer layer registration is not in git** and must be done per instance;
  there is **no down-migration**, so the retained renamed tables are a data rollback but not a
  schema or code one; and the pre-flight audit must have been run and acted on first. This now
  applies **twice**, once per release (decision 11).
- **Renaming a table in place breaks its readers immediately, by design.** Phase 1 step 9 is the
  moment any endpoint the inventory missed stops working, and it will look like a deploy failure
  rather than a missed dependency. That is the trade accepted in decision 16 — a loud failure over
  a silent stale read — but it means the Phase 4 endpoint table has to be right before Phase 1
  runs, not after.
- **The auto-route blockage fix changes routing results.** Phase 1 step 7 turns a feature that has
  been inverted back the right way round, so routes that previously ignored an Active blockage
  will start avoiding it. Anyone comparing a re-run against a stored route will see a difference.
  Audit report 9 sizes it in advance.
- **The schematic's Cytoscape classes are re-keyed in the same change as its views.** A class name
  that does not match renders an unstyled node rather than throwing, so this fails quietly. Check
  it visually, not by absence of console errors.
- **The one-table discipline.** A query that forgets `WHERE element_kind` silently returns wrong
  rows. Mitigated by reads going through views and writes through a single endpoint — but it is a
  convention, not a constraint, and needs stating in `CLAUDE.md` once built.
- **The second geometry column is dropped deliberately, and that is worth recording.** Cables,
  duct and subduct each have `geom` as well as `geom_user`; the new model has one geometry and
  takes `geom_user` (decision 18). Nothing reads or writes the bare `geom` today. Audit report 10
  captures its contents before the migration, so the discard is evidenced rather than assumed,
  and the renamed source tables keep it recoverable.
- **Free-text type values in live data.** The `editable_fields` lists may not cover everything
  actually stored, and the four status columns with no UI (audit report 1) are where to expect
  the gap. Phase 1 step 2 fails loudly rather than guessing; expect an iteration.
- **Geometry enforcement is trigger-based**, weaker than typmod. A bulk SQL write that bypasses the
  application still fires the trigger, but a `TRUNCATE`-and-reload would not.
- **Scope.** Eight phases, and Phase 6 is a new subsystem. Phases 0–3 are the foundation and could
  ship before 4–7 if the build needs breaking up.
- **GeoServer reconfiguration** is manual per layer and easy to forget on a client deployment,
  where deploys are `git pull` with no per-deploy step.
- **`admin_planner` must be wired, not just declared.** Two admin keys already exist in the roles
  UI that nothing checks (`admin_map_layers`, `admin_dist_analysis`). If `admin_planner` is added
  to `MODULES` without matching entries in `routes.php` and `$modePermissions`, granting Planner
  Configuration will silently do nothing while `admin_fields` confers it — the same latent bug,
  freshly made. Those two keys stay open on their own merits in
  `docs/improvement-opportunities.md`; this build does not resolve them.
- **`map_get_v2.php` is the easiest thing here to forget.** The layers are configured in
  `public.map_layers`, so grepping the code for a planner layer finds nothing, and the permission
  that actually serves them lives in one array in one endpoint. A correct `app_modules` row,
  correct role grants and a correct layer row still render an empty map if `$schemaModuleMap` has
  not been updated.
- **Type-scoped dynamic fields are new machinery in a shared system.** Phase 2 part 1 extends
  `fieldMetaModuleConfig()` for every module, not just planner. Worth landing behind the existing
  modules' behaviour being unchanged.
