# Table dependency chains

**What this is.** One section per table whose change reaches further than the table. Each
lists, in order, what must change with it and where that thing lives. The rule each chain
enforces stays in CLAUDE.md beside the feature it belongs to; this is the index across them,
so the next migration on a table finds its chain without reading every section.

**How to use it.** Before writing a migration that adds, renames or drops a column, or adds
a row to one of the registries below, read the table's section and walk the list. Where a
verify block checks part of the chain mechanically, it says so; everything else is review.
Add a section when a build creates a new chain, and add to an existing one when a build
adds a reader.

**Dates are when the chain was recorded, not when it was created.** Started 2026-10-07
during the project milestones build (`docs/2026-10-02-project-milestones.md`), which added
three readers to `planner.elements` at once.

---

## `planner.elements`

The planner's one element table. A column added here must reach:

1. **`planner.elements_history`** — the same column, since the history row is a copy of the
   element row (migration 070, rebuilt 080).
2. **`planner.fn_elements_history()`** (migration 080) — the no-op guard compares every plan
   column; a column left out of the comparison means a change to it writes no history row.
   The INSERT list below it too.
3. **`planner.vw_elements`** (migration 102) — the single planner view every editor layer
   reads; a column absent here is absent from the map.
4. **`planner.milestone_elements`** (migration 115) — the milestone payload mirrors the real
   columns of `planner.elements`.
5. **`planner.milestone_payload()`** (115) — the one definition of what a milestone captures.
   **115's verify block checks 4 and 5 agree column for column**, so a column added to one
   and not the other fails the migration.
6. **`project_milestone_compare.php`** — the column list in `sideSql()` and the comparisons
   in the row loop. Nothing checks this one; a column missing here is silently not compared.
7. **`plannerChangedSinceCount()`** in `global_functions.php` — the fingerprint over the plan
   columns of each history row. A plan column left out of the fingerprint means an edit to
   it does not count as an edit since the milestone; a presentation column added to it means
   a drag counts. `sld_x` / `sld_y` are deliberately out (decision 9).

Not in the chain: `planner.milestone_elements` has **no foreign key** to `planner.elements`,
deliberately — a snapshot outlives what it captured.

## `planner.project_milestones` and the milestone payload

- A new **payload** (beside elements, connections, splice schedule) is a table with
  `milestone_id` cascading from the header, an insert in `capture_milestone()`, a delete in
  `project_milestone_save.php`'s `delete` mode (soft delete removes payload rows and keeps
  the header), and a diff in `project_milestone_compare.php`.
- A new **header column** is also a line in `fn_project_milestones_guard()` (115), which
  lists every immutable column by name; a column left out is silently editable.
- **`promoted_datetime` / `promoted_user`** are the assets module's to add, and when they
  land the rule "a promoted milestone cannot be unlocked" goes into **`plan_locked_by()`**
  and nowhere else — the seven endpoints calling `requirePlanUnlocked()` do not change.

## `planner.milestone_types`

A registry. `is_as_built` is guarded by trigger once a milestone of the type exists (115);
`admin_save.php`'s `plannerMilestoneTypeSave()` refuses the same change with a sentence
first. A second behavioural flag follows the same pair: trigger in the database, courtesy
check in the save, and a copy onto `project_milestones` at capture so the header never
depends on the registry for its own semantics.

## `projects.projects.geom` — the boundary

The boundary is part of the plan although it lives on the project row. **Every writer of
the column is a lock site** and must call `requirePlanUnlocked()`:

1. `map_feature_save.php`, the `projectBoundary` branch — the save the editor actually posts
   the boundary to.
2. `map_boundary_update.php` — no callers since July 2026, gated anyway.
3. `project_save.php` — by omission: `geom` was removed from `$staticFields`, so a direct
   POST cannot write it. Putting it back would reopen the hole.

**Derive this list by grepping for writers of the column, never from the editor's JS**: the
milestones plan first named the dead endpoint and missed the live one that way. A new
boundary writer is a fourth entry here and a fourth call.

The planner's own writers are the other lock sites: `planner_save.php`,
`planner_ports_save.php`, `planner_connections_save.php`. **Not** `network_topo_save.php`
(presentation). The editor's tools follow through `map_layer_manager.php`, which turns
`editable` / `allow_create` / `allow_delete` off on planner layers and the boundary layer
when the plan is locked — a new planner layer gets that for free by schema; a new
boundary-like layer needs naming there.

## `projects.projects` — adding a column

**A column added here can break a query that never mentions it.** When `is_deleted` was
added, `opportunity_manage.php`'s nearby-projects CTE selected `projects.*` alongside
`o.is_deleted`, and the later bare `is_deleted` references became ambiguous. After adding a
column to an entity table, grep for star-selects of it.

The project's own history trigger copies the row, so the history table and trigger follow
the same pattern as `planner.elements` above.

## `projects.project_premises`

`source` has `CHECK (source IN ('boundary'))` and `refresh_project_premises()` is its only
writer. That single fact is what lets four readers filter `state = 'approved'` and need no
`is_deleted` filter of their own, because a deleted project tombstones every boundary row
through the trigger. **If a second source is ever added**, revisit:

1. The four project joins in `stocklist_load.php`.
2. The premise, validity and rival-claimant queries in `project_load.php`.
3. The premise arm of `wayleave.vw_wayleave_project_matches` (migration 112).
4. `premises_count` on `planner.project_milestones`, captured as the `is_valid` count.

## `public.map_layers`

**A migration that inserts a vector layer must set `is_system = true` itself.** 089's seed
flagged the vector layers present at the time and does not reach rows added later; 113
copied 063's pre-089 INSERT and landed three deletable layers, which 114 fixed. The verify
block in 114 re-asserts the invariant and is the shape to copy.

A layer served to the project editor also needs a `map_layer_page_config` row for the
`projectedit` page context, or it is not offered; the milestone compare page reads the same
list for its base and reference layers.

**`vector_style` names a bucket in `map_layer_utils.js`'s style managers, and no migration
can check it exists.** A polygon whose bucket or key does not resolve draws
`defaultStyles.grey`; a point draws nothing at all. The `statusStyles` bucket is built at
map init from `statusStyleRegistry()` (116), so a token added to the registry needs no JS
change — but a token whose `map` member is missing or names a CSS variable that does not
exist is skipped and draws grey.

**A row that predates 089 may carry NULL `database_schema` / `database_table`** and be named
only by its `url`. Match such a row on `url ~ 'geotable=<schema>.<view>(&|$)'`, which is what
`map_get_v2.php` reads; 116's first cut matched on the two columns, found nothing, and
raised. Set the columns while touching the row so the next migration can use them.

## `wayleave.wayleave_coverage`

One row per premise per source. A new **source** is a fifth arm in the source views, each
as `UNION ALL` of what the source produces now and decided rows it no longer produces,
taking `wayleave_id` from a real column — never a `FULL JOIN` on a `COALESCE`d key (048).
`vw_wayleave_pending` diffs each source's live resolution against the approved rows, so the
new source needs its resolution there too.

## The planner history tables

Nine tables feed two readers: `plannerChangedSinceCount()` (the edits-since chip) and the
compare page's History tab (`project_milestone_compare.php`, `history` mode). A **tenth
history table** is a tenth arm in both `UNION ALL`s. Two shapes to know: the EAV history
tables carry `record_datetime` / `record_user` and no project id, reaching the project
through `element_id`; the others carry `history_datetime` / `history_user`, and only
`elements_history` and `element_connections_history` carry `project_id` directly. 115 added
the indexes those two readers filter on.
