- unit tests for resolve_group_ambiguity (distinct addresses withheld, same-address re-listing kept, order preserved) - Postgres integration tests for upsert_all's backstop dedup - ADR-0057 recording the "confirm UPRNs before finalise" decision Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4.4 KiB
UPRN confirmation precedes finalise; the bulk-upload review is one two-tab page and the portfolio "unmatched" tab is retired
Status
proposed
Context
The bulk-upload pipeline is a chain of SQS-driven Lambda stages:
address2uprn → landlord-overrides → combiner → (review) → finaliser.
address2uprn matches each input row independently against per-postcode EPC
candidates, keeping the best candidate above a lexiscore >= 0.7 floor. There
is no cross-row uniqueness guard: nothing stops one UPRN being the best
match for two different addresses. The textbook case is a coarse EPC record
42 Moreton Road (no flat token) that Flat 1, 42 Moreton Road and
Flat 2, 42 Moreton Road both clear — the building-number guard passes and the
flat guard never fires, so both distinct flats collapse onto one UPRN.
Downstream this is corrosive, not just noisy:
- The
propertyidentity insert ison_conflict_do_nothingon the partial unique index(portfolio_id, uprn) WHERE uprn IS NOT NULL. Two distinct addresses carrying the same UPRN therefore merge into a single property — one address silently loses its identity. _build_overrides(the finaliser) keysproperty_overrideson(property_id, override_component, building_part). Both merged rows emit the same tuple, so the singleINSERT ... ON CONFLICT DO UPDATEraisesCardinalityViolation("cannot affect row a second time").
The classifier and finaliser were built assuming a UPRN is always intact and unique; it is not. Separately, a post-onboarding portfolio-level "Unmatched properties" tab let users fix no-UPRN properties after the fact — too late (the identity merge has already happened) and in a different place from the rest of review.
Decision
address2uprnemits a UPRN only when confident and unambiguous. Within a postcode group, a UPRN that is the best match for two or more rows whose normalised input addresses differ is withheld (dropped to null) on every such row; a UPRN shared only by rows with the same normalised address is a genuine re-listing and is kept.address2uprnemits anaddress2uprn_statuscolumn (matched | ambiguous_duplicate | unmatched | invalid_postcode | error); withheld rows keep theirlexiscorefor triage. Alexiscorereview band[0.7, X)that also withholds shaky matches is a follow-on onceXis calibrated (see Consequences).- One pre-finalise review page, two tabs, styled like the retired unmatched
tab:
- Addresses — the flagged rows (
unmatched/ambiguous_duplicate/ low-score); the user resolves each via OS Places (reusing the existingMatchAddress/searchAddresses/useAssignUprnflow +postcode_searchcache), producing a valid residential UPRN, or explicitly marks no-UPRN. - Classification — the existing description→value verify gate, semantics
unchanged (every
Unknownmust be mapped; the finaliser still fails loud on an unresolved value). Finalise is enabled only when both tabs are clear.
- Addresses — the flagged rows (
- The finaliser runs only after confirmation, so every row carries a confirmed
UPRN or an explicit no-UPRN (a first-class terminal state:
propertyidentity withuprn NULL, noproperty_overrides, building-passport shows the "not matched yet" card). - Retire the portfolio-level "Unmatched properties" tab — properties now arrive pre-matched, so post-onboarding UPRN cleanup is unnecessary.
PropertyOverridePostgresRepository.upsert_allkeeps a defensive last-write-wins dedup as a backstop for the single-statement invariant, but it is no longer the primary fix.
Consequences
- Distinct addresses can no longer silently merge into one property; the
CardinalityViolationcan no longer arise from real data. - Onboarding reaching "complete" now implies confirmed identities, so portfolio pages need no unmatched tab.
- The ambiguous/low-score subset adds bounded user work at review time; confident rows pass untouched.
- The
lexiscorereview-band thresholdXneeds calibrating against a real portfolio's score distribution before it is switched on; until then only the ambiguous-duplicate rule (which needs no threshold) is active. - Migration is safe stage-by-stage: withheld UPRNs are caught by the existing unmatched tab until the two-tab page ships, and the tab is removed only after it does — so an ambiguous property always has somewhere to be resolved.