18 KiB
Handover: bulk_upload_finaliser v2 — populate property_overrides
Purpose. Self-contained brief to start a fresh context implementing v2 (the per-Property fact layer) in the existing
bulk_upload_finaliserLambda. v1 (async finalise that writesproperty) is shipped and working end-to-end. This doc assumes no memory of the v1 session.
0. Design resolved — grilling outcome (2026-06-05)
The open questions in §9 were resolved in a design session. This section is now authoritative; the later sections are kept for background but where they conflict with this one, this one wins. The new v2 ADR is
docs/adr/0006-property-overrides-join-and-no-uprn-defer.md; ADR-0004 was amended for per-count ordering capture.
Spine. Populate property_overrides at finalise for UPRN-matched rows only.
Join the classifier descriptions to the combiner identity by a synthetic UUID
source_row_id — not Internal Reference (it is absent from the classifier
CSV, and optional anyway) and not by carrying description columns through
address2uprn (architecture B, rejected). This is architecture (A) with a
purpose-built key.
No-UPRN rows are deferred to v3. v1 currently inserts them as property rows;
v2 changes nothing in the property insert and simply writes no overrides for
them. The eventual home for unmatched rows is a separate staging table (Model B):
property holds only matched rows; unmatched inputs (with their descriptions) live in
the staging table until a different UPRN matcher assigns a UPRN and promotes them.
"Found vs unfound" is a view across both tables, not a flag on property. v3 owns
the property-insert change + the staging table + the matcher-rerun UX together.
Frontend work (/workspaces/assessment-model):
- Mint
source_row_id(UUID) instart-address-matchingright afterreadRows(), and explicitly emit it as a column in bothbuildAddressCsvandbuildClassifierCsv— both project a fixed column set, so attaching it to the row object is not enough. It survivesaddress2uprn→combiner like any input column (carried asadditional_info); verify against a real combiner output. - Per-count ordering capture (supersedes ADR-0004's largest-count-only):
detectMultiEntrykeeps a sample per distinct count;OnboardingProgressrenders one ordering panel per count ≥ 2. The jsonb type andsetMultiEntryOrderingvalidation already accept all counts — no migration, no backend-validation change. - Verify gate hardened: Finalise is blocked while any description is still
UNKNOWN.UNKNOWNis now a transient "needs review" marker, never a final value (this retires the old "UNKNOWNis legitimate" line in §7). dispatchFinaliseradds two fields to the trigger body:classifier_s3_uriandmulti_entry_ordering(it already reads thebulk_address_uploadsrow, and dispatch happens after the user confirms ordering, so the value is final). The classifier S3 key comes from a sharedclassifierCsvKey(portfolioId, uploadId)helper used by both the writer and the dispatcher (the key is not stored anywhere today — convention only).
Backend work (/workspaces/home/github/Model):
5. Grow the trigger schema in two places — FastAPI FinaliserTriggerRequest and
Lambda BulkUploadFinaliserTriggerBody — with classifier_s3_uri +
multi_entry_ordering. Handler stays trigger-driven (no new bulk_address_uploads
coupling).
6. PropertyOverrideRow table mirror + a sibling PropertyOverrideRepository
(own aggregate; upsert on (property_id, override_component, building_part)), and a
read-only LandlordOverrideRepository that loads a portfolio's vocabulary
per component into dicts once (the vocabulary is deduplicated, not per-row).
7. Orchestrator step, in the same commit_scope:
- bulk
SELECT (portfolio_id, uprn) → idfor the run's UPRN rows → in-memory map; - join classifier↔combiner rows by
source_row_id; - uniform comma-split all four components →
permutations[count]→ parts (count-1 cell →building_part = 0); the finaliser needs no fallback because every count ≥ 2 has a confirmed permutation; - resolve each part's normalized description against the override dicts;
original_spreadsheet_description= the raw entry text (un-normalized);- empty cell → write no row; non-empty but unresolved (or
UNKNOWN) → raise →commit_scoperolls back →_mark_failedflips the upload tofailed(fail loudly, no partial writes); - write only the classifier components actually mapped in
columnMapping; - no
sourcecolumn in v2 — upsert is unconditional for now.
Locked assumptions (load-bearing — see ADR-0006).
- One real upload per user. A re-upload only adds new properties (ones not previously included), never re-describes existing ones → part-keys are append-only across uploads → upsert-only, no delete-orphans is correct and complete.
- Per-count consistency. One ordering per count, confirmed from one sample, applies to every cell of that count in the file (extends ADR-0004's bet to all counts).
- Per-cell count.
Wallsmay split into 3 whileRoofssplits into 2 in the same row; each cell is ordered by its own entry count. - Classification completes before
awaiting_review, and the hardened verify gate forces everyUNKNOWNto be resolved — so an unresolved description at finalise is a genuine defect, hence fail-loud.
1. Where v1 left things (read first)
v1 made Finalise an async dispatched Lambda that writes property rows. The
full flow works in dev: dispatch 202 → SQS → Lambda inserts properties + writes
terminal status → UI advances to "Processing complete".
Authoritative background — read these before coding:
docs/design/bulk-upload-finaliser.md— the full grilling/design doc (schema Q6–Q9, snapshot-not-FK, recalculate-on-rerun, the v2 input hazards).docs/adr/0005-async-bulk-upload-finaliser.md(frontend) — state machine +property_overridesshape./workspaces/home/github/Model/docs/adr/0013-bulk-upload-finaliser-writes-properties.md(backend) — the Lambda write path + DDD layering.docs/adr/0004-multi-entry-building-part-ordering.md— critical for v2: how building parts andmultiEntryOrderingwork.docs/adr/0002-landlord-override-vocabulary.md— the vocabulary (landlord_*_overrides) layer v2 resolves against.CONTEXT.md— glossary: Property override, Building part, Main building, Extension, Multi-entry, Building-part ordering, VocabularyMapping.
Convention that must hold (it was corrected hard in v1): in the Model repo,
business logic lives in orchestration/*_orchestrator.py; the Lambda
applications/*/handler.py stays thin (parse trigger, wire infra, delegate). One
repository per aggregate; orchestrators never commit (the handler owns the
transaction via commit_scope). See memory model-ddd-layering.
2. v2 goal
Populate property_overrides during finalise: for each property, write one row per
(building_part, override_component) carrying the resolved enum snapshot of the
landlord's description for that part.
3. The target table (already shipped — migration 0221, do NOT re-migrate)
Drizzle: src/app/db/schema/property_overrides.ts.
property_overrides
id uuid pk
property_id bigint NOT NULL FK → property.id ON DELETE CASCADE
portfolio_id bigint NOT NULL FK → portfolio.id ON DELETE CASCADE
building_part smallint NOT NULL -- 0 = main, 1 = ext 1, 2 = ext 2, …
override_component override_component NOT NULL -- pgEnum {wall_type, roof_type, property_type, built_form_type}
override_value text NOT NULL -- snapshot of the resolved enum value
original_spreadsheet_description text NOT NULL -- raw cell text it resolved from
created_at / updated_at timestamptz NOT NULL
UNIQUE (property_id, override_component, building_part)
4. Design decisions already locked (do not relitigate)
- Snapshot, not FK.
override_valueis a denormalised text copy of the resolved enum, taken at materialise time — not an FK tolandlord_*_overrides. This is what lets two properties sharing a description diverge later, and is required because there are four polymorphic vocabulary tables. Lineage is the natural key(portfolio_id, override_component, original_spreadsheet_description). - Re-run = recalculate. Write with
onConflictDoUpdateon(property_id, override_component, building_part), refreshingoverride_value+original_spreadsheet_description+updated_at. (Contrastproperty, which isonConflictDoNothing.) When a per-property user-edit path eventually exists, this upsert will need aWHERE source='classifier'guard — but there is nosourcecolumn in v1; add it as a nullable column only when that path is built. override_componentvalues are exactly the classifier category keys (wall_type,roof_type,property_type,built_form_type) used in bothsrc/lib/bulkUpload/columnFields.tsand the ModelClassifiableColumn.name— no translation.building_partindexing:0 = Main building,1 = Extension 1, … per ADR-0004.- Whole-dwelling components (
property_type,built_form_type) are per-part- capable but today's files supply them once → usually written atbuilding_part = 0.
5. The hard part: assembling the inputs (this is the real v2 work)
The combiner output (what the v1 finaliser reads) carries only address/UPRN
columns — Address 1/2/3, postcode, Internal Reference, address2uprn_uprn,
address2uprn_address, address2uprn_lexiscore. The raw Walls/Roofs/
Property Type/Built Form cells are NOT in it. They live only in:
- the classifier CSV on S3 —
bulk_onboarding_inputs/{portfolioId}/{uploadId}-classifier.csv(original landlord headers), and landlord_*_overridesin Postgres — the resolved values keyed by(portfolio_id, normalized description).
To write one property_overrides row, v2 must assemble four inputs:
| Need | Source |
|---|---|
property_id (identity) |
combiner output → (portfolio_id, uprn) … but no-UPRN rows have no key |
| raw cell text per row | the classifier CSV (not the combiner output) |
| split a multi-valued cell → building parts | multiEntryOrdering on bulk_address_uploads |
description → override_value |
landlord_*_overrides (resolve by normalized description) |
Two open hazards — both RESOLVED (see §0)
-
Join key (RESOLVED). Investigation confirmed
Internal Referenceis in the address CSV + combiner output but NOT in the classifier CSV, and is optional. So architecture (A)-by-Internal Referenceis dead. Resolution: mint a synthetic UUIDsource_row_idinstart-address-matchingafterreadRows(), emitted as an explicit column in both CSVs. It is the join key. (Architecture (A) with a purpose-built key; (B) "carry descriptions throughaddress2uprn" was rejected.) -
property_idfor no-UPRN rows (RESOLVED by descoping). v2 writes overrides only for UPRN rows, whoseproperty.idis re-found by(portfolio_id, uprn)— so noRETURNINGcorrelation is needed. No-UPRN rows are deferred to v3 (Model B staging table); v2 leaves the property insert untouched.
6. multiEntryOrdering — how to split cells into parts
Persisted on bulk_address_uploads (src/app/db/schema/bulk_address_uploads.ts):
MultiEntryOrdering { permutations: Record<string, number[]>; confirmed: boolean }
// permutations[count][k] = the 0-based FILE position holding building part k
// where 0 = Main building, 1..N-1 = Extension 1..N-1.
// e.g. { "2": [1, 0] } => for 2-entry rows, the main building is file position 1.
A multi-valued cell (e.g. Walls = "Cavity: …, Solid brick: …") splits on commas
into entries by file position; permutations[count] maps file position → building
part. Caveat (ADR-0004): only the largest count permutation is captured this
iteration; other counts need a derivation rule — decide it in v2.
multiEntrySummary holds the detected multi-valued columns + normalized
description keys (the normalization that matches the classifier's stored keys:
split → strip → lower).
7. Resolving description → value (landlord_*_overrides)
Four per-component tables in src/app/db/schema/landlord_overrides.ts
(landlord_wall_type_overrides, …_roof_type_…, …_property_type_…,
…_built_form_type_…), each UNIQUE (portfolio_id, description), value typed by the
component's pgEnum, plus a source (classifier|user). Resolve a normalized
description → value. The frontend already does this read in
src/lib/bulkUpload/server.ts (lookupOverrides) — mirror that mapping on the
backend. UNKNOWN is now a transient "needs review" marker, never a final
value (resolved in §0): the verify gate forces the user to map every UNKNOWN
before Finalise, so a UNKNOWN (or unresolvable description) reaching the finaliser
is a defect and fails the run loudly.
8. Backend pieces to build (DDD, mirror v1)
In /workspaces/home/github/Model:
PropertyOverrideRowSQLModel mirror →infrastructure/postgres/property_override_table.py(mirror the pattern inproperty_table.py/landlord_*_override_table.py; reuse a sharedoverride_componentSAEnum likelandlord_override_enums.py).- Repository for the override write (one per aggregate): add to
repositories/property/(e.g. extend the property repo or a siblingproperty_overriderepo), with anupsert_allusingon_conflict_do_update(index_elements=[property_id, override_component, building_part], …). - Orchestrator logic in
orchestration/bulk_upload_finaliser_orchestrator.py: extendfinalise(...)(or add a step) to, after inserting properties and getting ids, build the override rows (join → split by part → resolve) and persist them in the samecommit_scope. - Handler stays thin — it already wires S3 + engine + repos. It will need the
extra input (classifier CSV and/or
multiEntryOrdering); decide how those reach the Lambda (extendBulkUploadFinaliserTriggerBody, or readbulk_address_uploadsformultiEntryOrdering+ the classifier S3 URI). The trigger currently carriestask_id, sub_task_id, s3_uri (combiner output), portfolio_id, bulk_upload_id.
Key v1 files to extend (all in the Model repo):
applications/bulk_upload_finaliser/handler.pyorchestration/bulk_upload_finaliser_orchestrator.pyrepositories/property/property_repository.py+property_postgres_repository.pyinfrastructure/postgres/property_table.py(reference for the new mirror)infrastructure/s3/csv_s3_client.py(read_rows)- Packaging test:
tests/test_lambda_packaging.pywill flag any new top-level import the Dockerfile doesn'tCOPY(v1 hit this withdatatypes/).
9. Open questions — all RESOLVED (see §0 + ADR-0006)
- Join key → synthetic UUID
source_row_idin both CSVs (notInternal Reference, not architecture B). property_idfor no-UPRN rows → out of scope; v2 is UPRN-only, no-UPRN deferred to v3 (Model B). UPRN rows re-found by(portfolio_id, uprn); noRETURNING.- Non-largest-count
multiEntryOrdering→ capture a confirmed permutation for every count ≥ 2 in the UI (supersedes ADR-0004); finaliser needs no fallback. - Trigger body vs handler-reads-DB → grow the trigger body (
classifier_s3_urimulti_entry_ordering), built indispatchFinaliser.
- Re-materialise → recalculate every finalise via upsert-only on
(property_id, override_component, building_part); no delete-orphans (justified by the one-real-upload assumption);propertyrows untouched.
10. Implementation order (design is settled — build it)
Frontend first (the finaliser depends on source_row_id + per-count ordering):
source_row_id: sharedclassifierCsvKeyhelper; mint the UUID instart-address-matchingafterreadRows(); emit it as an explicit column in bothbuildAddressCsvandbuildClassifierCsv. Verify it lands in a real combiner output.- Per-count ordering:
detectMultiEntrykeeps a sample per count;OnboardingProgressrenders one ordering panel per count ≥ 2. Drop the largest-count-only assumption insetMultiEntryOrderingif it requires the largest. - Verify gate: block Finalise while any classification is
UNKNOWN. dispatchFinaliser: addclassifier_s3_uri+multi_entry_orderingto the trigger payload.
Backend:
- Grow
FinaliserTriggerRequest(FastAPI) +BulkUploadFinaliserTriggerBody(Lambda). PropertyOverrideRowmirror + siblingPropertyOverrideRepository(upsert) + read-onlyLandlordOverrideRepository.- Orchestrator step (join → split → resolve → upsert; fail-loud on unresolved),
TDD against fakes (mirror
tests/orchestration/test_bulk_upload_finaliser_orchestrator.py). - Handler wiring; watch
tests/test_lambda_packaging.pyfor Dockerfile COPY gaps.
Docs (done in this session): ADR-0004 amended, ADR-0006 added, CONTEXT.md
"Property override" updated.
11. Verification notes (environment)
- Frontend:
npx tsc --noEmit(was 0 errors at v1 close). - Model repo:
mypy/pytestneed a deps-installed env (the v1 session couldn't run them locally;/appDocker config runs the full suite).terraform planneeds the CLI. Watchtests/test_lambda_packaging.pyfor Dockerfile COPY gaps. - v1 is committed; dev Lambda + SQS queue are deployed and working
(
FINALISER_SQS_URLwired inbackend/.envfor local, and in terraform/fast-api).