Model/docs/adr/0020-conservation-status-as-property-attributes.md
Khalim Conn-Kowlessar e8212544ed docs(adr): record slice 3c persistence + unknown-default decisions (ADR-0020)
Pin the resolution reached in the grill: planning status persists as a
per-UPRN write-through cache in the existing `property_details_spatial` table
(not FE-property columns), read back off the Property in Modelling; unknown
UPRN defaults to unrestricted, matching legacy `empty_spatial_df` (superseding
the earlier "conservative stance" note).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 18:26:21 +00:00

4.3 KiB

Conservation / Listed / Heritage as distinct Property attributes

Wall Insulation Eligibility (ADR-0019) — and later Solar-PV and Windows generators — gate on a Property's planning status. That status is not on the EPC and not in the OS Open-UPRN parquet the geospatial Repo reads today (it only yields coordinates); legacy derives it by spatial-joining separate conservation/listed/heritage layers (OpenUprnClient.set_spatial_dataproperty_spatial table). We decided to model it as three distinct boolean Property attributesin_conservation_area, is_listed, is_heritage — resolved through the geospatial layer during Ingestion and read off the Property (not the EPC), because they describe the building's location/protection, not its energy fabric.

Decision

  • Keep the three flags separate, not legacy's collapsed restricted_measures boolean — they gate different Measure Options (conservation blocks EWI only; listed/heritage block both — ADR-0019).
  • Surface them via the geospatial Repo (a GeospatialRepository method returning a planning-status record, alongside coordinates_for), persist onto the Property in Ingestion, and pass them into the generator as an explicit input.
  • Build this as the final integrating slice of the solid-wall feature (build order A), after the calculator mechanics and generator are pinned — it also unlocks the PV/Windows gates.

Source (decided)

The conservation/listed/heritage flags are co-located with longitude/latitude in the same S3 partition the geospatial Repo already reads — so we extend the existing GeospatialS3Repository to surface those extra columns alongside the coordinates, rather than porting a separate spatial-join or reading the legacy property_spatial table. A further deep-dive into the exact S3 columns/shape precedes slice 3.

Persistence (decided, slice 3c)

The OS Open-UPRN reference set is tens of millions of rows — too large to host in Postgres — so it lives in S3 and is resolved on demand. The flags must nonetheless reach the front-end (the FE displays them on the property_details_spatial view so a user can see why a Property did or did not get a given measure), and the FE reads Postgres, not S3. So Ingestion follows a write-through cache: fetch the spatial reference row from S3, use the coordinates to drive the Solar fetch, and persist the whole row (coordinates + three flags) into the existing property_details_spatial table, keyed by UPRN (one shared row per UPRN, not per Property — uprn is unique; cf. legacy bulk_upsert_property_spatial's on_conflict_do_update). Modelling reads the flags back off the Property (PropertyPostgresRepository hydrates Property.planning_restrictions from that table by UPRN) — the stage boundary stays repo-mediated (ADR-0011); Modelling never touches S3.

This is the resolution of the earlier "persist onto the Property" wording: the flags are a Property attribute in the domain sense (an enrichment hydrated into the aggregate, exactly like the EPC is hydrated from epc_property), persisted in a backend-written reference table, not as columns on the FE-owned property row.

Unknown default — resolved to unrestricted (allow EWI). When S3 has no row for a UPRN (or the cache has none yet), the Property hydrates to PlanningRestrictions() — all flags False. This matches what legacy actually does (OpenUprnClient.empty_spatial_df sets all three flags False; the nearest-UPRN proxy keeps the flags and only nulls coordinates), so the Consequences note below — which read legacy as conservatively blocking on missing data — was mistaken about legacy and is superseded: we do not suppress a valid measure on absent evidence. UPRN is now a required identifier (it stitches the datasets together), so "Property with no UPRN" is an edge handled by the same unrestricted fallback rather than a designed-for case.

Consequences

  • Generators that need planning status take it as an input or read it off the Property; it never lands on EpcPropertyData.
  • Until this slice ships, the ADR-0019 gate defaults to "unrestricted" (offline only).
  • Mirrors legacy's conservative stance where missing data implied restriction — the source slice should decide the "unknown" default explicitly (block EWI vs allow) rather than silently allowing.