# Design WIP: `bulk_upload_finaliser` + `property_overrides` > **Status:** v1 fully resolved (grilling 2026-06-04). Ready to graduate to ADR(s). > v2 (`property_overrides` population) deferred to its own session — see the > "Input" application-flow item for its entry point. When decisions stabilise this should > graduate into a new ADR in `docs/adr/` (frontend) and likely a companion ADR > in the Model repo, plus a CONTEXT.md update (see "Docs to update"). ## Goal Two linked pieces of work: 1. **New backend application `bulk_upload_finaliser`** (lives in `/workspaces/home/github/Model/applications/`, DDD-aligned — study `/workspaces/home/github/Model/domain`). It reads the address-matching / combiner output **and** the `landlord_*_overrides` vocabulary tables, then writes Postgres correctly: the `property` rows (UPRN + address, as the frontend does today) and — later — the new `property_overrides` rows. Motivation: a property list can be ~40,000 rows, too big for a synchronous Next.js HTTP handler. 2. **New `property_overrides` table** — the per-Property fact layer that **ADR-0004 explicitly deferred** ("the per-Property building-part fact layer that consumes `multiEntryOrdering` and writes main/extension facts at finalise"). One row per `(property, building_part, component)` carrying the resolved enum value + provenance. **Split into two pieces (decided 2026-06-04):** - **v1 — async finaliser writes `property`.** Move today's synchronous Next.js `/finalize` property-insert into a dispatched Lambda (`bulk_upload_finaliser`), because a property list can be ~40,000 rows. Reproduces the exact 9-column insert + `onConflictDoNothing`, adds the `finalising` status + async state machine, and shifts terminal-status ownership to the backend. **Fully designed — ADR-ready.** - **v2 — populate `property_overrides`.** The per-Property fact layer. The *table* already shipped (migration 0221, PR #306), but population is a **separate follow-up** with its own open input-plumbing questions (see the "Input" item under application-flow questions). Not designed here. This doc resolves **v1 in full**; v2 gets its own grilling session against real classifier-CSV / combiner-output samples. ## Where this sits in the existing pipeline ``` BulkUpload → address matching → combiner → awaiting_review → [Finalise] │ (new) bulk_upload_finaliser ──────────┘ reads: combiner output (S3) + landlord_*_overrides writes: property (+ later property_overrides) │ downstream: Ingestion (EPC/solar fetch) → PropertyBaseline (stage 2, re-score-on-override seam, Model ADR-0011/0012) ``` `Finalise` (the user action + state-machine gate) stays in Next.js; the new application is the **worker it dispatches**. Downstream `PropertyBaseline` already has an override-aware "re-score" seam — `property_overrides` will feed it. ## Decisions locked | # | Decision | |---|----------| | Name | Application is **`bulk_upload_finaliser`**, in `Model/applications/`. `Finalise` stays the Next.js action that triggers it. | | DDD | Follow the DDD structure under `Model/domain`. Domain terms discovered as needed. | | Schema ownership | **Drizzle (frontend) owns migrations** for both `property` and the new `property_overrides`. | | Backend access | Backend gets a **`PropertyOverrideRow` SQLModel** (mirror, like `landlord_wall_type_override_table.py`) + a **repository** (see `Model/infrastructure/postgres` + `Model/repositories` for examples). `PropertyRow` must drop its "backend never inserts" invariant and gain insertable columns. | | Next.js `/finalize` | **Delete it** — fully replaced by the Lambda. | | `property_overrides` shape | **Single polymorphic table**, not per-component tables. Accepts losing DB-level pgEnum typing on `value`. | | `override_value` | `text` — a **denormalised snapshot copy** (own value per row) of the resolved enum from `landlord_*_overrides` at materialise time. Own-value (not an FK to the vocabulary) is what lets two properties sharing a description later **diverge**, and lets re-run recalculate one property's value without touching its siblings. | | Snapshot, **not** FK to vocabulary | `property_overrides` does **not** foreign-key the originating `landlord_*_overrides` row. An FK forces every property sharing a description to share one value (forbids divergence); is structurally impossible as a real FK (4 polymorphic target tables, each with its own value enum); and would risk cascade-deleting per-property facts when re-classification prunes a vocabulary row. Lineage is preserved as a **natural key** — `(portfolio_id, override_component, original_spreadsheet_description)` re-finds the vocabulary row (its `UNIQUE` is `(portfolio_id, description)`) — so deliberate re-sync needs no surrogate FK. | | Re-run = **recalculate** | The finaliser write to `property_overrides` is `onConflictDoUpdate` on `(property_id, override_component, building_part)`, refreshing `override_value` + `original_spreadsheet_description` + `updated_at` to the latest resolution. Contrast `property`, which stays `onConflictDoNothing` (identity rows, don't churn). When per-property `source='user'` edits exist, the update must guard `WHERE source='classifier'` to preserve hand-edits (mirrors the Model classifier upsert). | | `building_part` | **`smallint NOT NULL`**, explicit index: `0 = main building, 1 = extension 1, 2 = extension 2, …` (matches ADR-0004 `multiEntryOrdering.permutations` indexing). | | Whole-dwelling components | **No special case.** `property_type`/`built_form` are *per-part-capable* too (an extension — conservatory, summer house — can be a different built form / property type). Today's files only supply them once, so they'll usually be written at `building_part = 0` only, but the schema allows per-part with no future migration. | ## `property_overrides` — columns so far Roughly (subject to remaining open questions): ``` property_overrides id uuid pk (default random) -- match landlord_* tables property_id bigint NOT NULL FK → property.id (FE-owned table) portfolio_id bigint NOT NULL FK → portfolio.id building_part smallint NOT NULL -- 0 = main, 1 = ext 1, 2 = ext 2, … override_component override_component NOT NULL -- column name == enum type name; pgEnum {wall_type, roof_type, property_type, built_form_type} (Q6 ✓) override_value text NOT NULL -- snapshot copy of landlord_* resolved enum (free text; `override_component` carries the typing) -- (no `source`) — dropped Q9: pure value snapshot; add back as nullable column if/when a per-property edit path needs provenance original_spreadsheet_description text NOT NULL -- raw spreadsheet cell text this snapshot resolved from (Q7 ✓) created_at timestamptz NOT NULL default now() updated_at timestamptz NOT NULL default now() -- UNIQUE (property_id, override_component, building_part) -- Q8 ✓ (source NOT in key — mirrors ADR-0004 single-row flip; portfolio_id implied by property_id) -- FK property_id → property.id ON DELETE CASCADE; portfolio_id → portfolio.id (Drizzle only; bare bigint in SQLModel mirror); portfolio_id kept (matches property_details_epc / property_targets) ``` ## Open questions (resume here) - **Q6 — `component` discriminator. RESOLVED 2026-06-04.** pgEnum **`override_component`** (column `component`) with values **`wall_type`, `roof_type`, `property_type`, `built_form_type`**. Verified these are the *exact* keys used both in the frontend ([columnFields.ts:30-33](../../src/lib/bulkUpload/columnFields.ts#L30-L33)) and the backend (`ClassifiableColumn.name` / handler `_build_columns()`), so the finaliser maps category → component with **no translation**. pgEnum over text: small closed set, typos caught at write time — and this is now the *only* DB-level typing left on a row, since `override_value` is free text. New component = one-line `ALTER TYPE … ADD VALUE` (Drizzle-owned). Enum named `override_*` (not `property_*`) to sit with `override_source` and stay visually distinct from the existing *value* enum `property_type`. - **Q7 — store raw description per override row? RESOLVED 2026-06-04: yes, as `original_spreadsheet_description text NOT NULL`.** Names the *source artifact* (the spreadsheet cell), not an actor — sidesteps the Landlord-vs-User conflation the glossary warns against, and aligns with CONTEXT.md's "the source file". Stored because `override_value` is a denormalised snapshot that deliberately won't refresh on later vocabulary edits; pinning the original text makes each row self-explaining and re-resolvable even after the source `landlord_*_overrides` row changes. `NOT NULL` is safe **iff** every `property_overrides` row is materialised from a `landlord_*_overrides` row (whose `description` is itself `NOT NULL`) — confirm when settling Q9/source semantics. - **Q8 — uniqueness + FKs. RESOLVED 2026-06-04.** `UNIQUE (property_id, override_component, building_part)`. `building_part` is in the key (part 0 and part 1 both carry e.g. a `wall_type` row). `source` is **deliberately not** in the key — mirrors ADR-0004's single-row-flip (one row, flip `source` in place; the two-row model was rejected). `portfolio_id` is not in the key (implied by `property_id`) but **is kept as a column** for query ergonomics and consistency with `property_details_epc` / `property_targets`, which both denormalise it. FKs: `property_id → property.id ON DELETE CASCADE`; `portfolio_id → portfolio.id ON DELETE CASCADE` in the Drizzle migration, but a bare `bigint` (no FK) in the backend `PropertyOverrideRow` SQLModel mirror — matching `landlord_wall_type_override_table.py`. - **Q9 — `source` semantics. RESOLVED 2026-06-04: drop `source` entirely.** `property_overrides` is a pure **snapshot of resolved values**. Rationale: there is no per-property override concept today (per ADR-0004 edits happen at the **vocabulary/portfolio** level, flipped in place), so a copied `source` would describe the *vocabulary mapping's* provenance, not this property's — a footgun a reader/re-score rule could misread, and no consumer needs it in v1. When a genuine per-property edit path lands (the real use for per-property provenance), `source` returns as an **additive nullable-column migration** — no need to carry it now. This also confirms the Q7 `NOT NULL` contingency: every row is still materialised from a `landlord_*_overrides` row (`description NOT NULL`). - **Q-scope — v1 scope. RESOLVED 2026-06-04.** v1 = the finaliser reproduces today's **exact 9-column `property` insert** (`portfolio_id`, `creation_status='READY'`, `uprn`, `landlord_property_id` ← `Internal Reference`, `address` = matched ?? user-inputted, `postcode`, `user_inputted_address`, `user_inputted_postcode`, `lexiscore`) **+** `onConflictDoNothing` on `(portfolio_id, uprn) where uprn is not null` — not a reduced "UPRN + address". This sizes the "PropertyRow gains insertable columns" decision to all nine columns plus `creation_status`. The `property_overrides` *table* shipped ahead (migration 0221, PR #306) but is **not populated** in v1 — population is follow-up work (and needs a different input source; see combiner-output note below). ### Application-flow questions not yet reached - **Trigger + orchestration. RESOLVED 2026-06-04.** Mirror the `start-address-matching` path. Next.js creates a `SubTask` (`service: "finaliser"`) under the BulkUpload's existing Task, then POSTs a new FastAPI endpoint `POST /v1/bulk-uploads/trigger-finaliser` (auth via `validate_token`), which enqueues to a **new SQS queue**; a Lambda runs the finaliser wrapped in `@subtask_handler` (auto-injected `TaskOrchestrator`; `run_subtask` owns the subtask start/complete/fail + Task cascade). Trigger body `FinaliserTriggerBody { task_id, sub_task_id, s3_uri (combined output), portfolio_id }` (extends `SubtaskTriggerBody`). Slow work stays outside the txn; persistence in a `commit_scope`. The synchronous Next.js `/finalize` route is deleted (locked). - **State machine / who writes `complete`. RESOLVED 2026-06-04.** New status **`finalising`** between `awaiting_review` and `complete` (mirrors `combining` before `awaiting_review`). Lifecycle: `awaiting_review → finalising → complete` (↘ `failed`). - **`finalising`** written by **Next.js at dispatch** via a **compare-and-swap**: `UPDATE … SET status='finalising' WHERE id=? AND status='awaiting_review'` — 0 rows ⇒ already dispatched ⇒ 409. This is the **double-dispatch guard** (closes the simultaneous-click race under `loadForFinalize`'s existing precondition). - **`complete` / `failed`** written by the **Lambda** directly to `bulk_address_uploads` (new `set_finalized_status` / `set_failed_status`, exactly like the combiner's `set_combining_status` / `set_combined_output_s3_uri`). `markFinalized` + the Next.js `/finalize` route are deleted. - **CONTEXT.md "Two writers" change:** Next.js owns dispatch + the `awaiting_review → finalising` CAS; the backend owns `finalising → complete` and `→ failed` (in addition to `combining` / `awaiting_review`). - **UI vs canonical:** persisted enum value is `finalising` (canonical; ties to the **Finalise** action). The frontend renders it as **"Uploading to ARA"** — a display-layer label only, **not** the enum name, so UX copy never needs a migration. - **Input — does the combiner output carry the raw description cells? RESOLVED 2026-06-04: NO. This is a v2 problem (deferred).** v1 needs only address/UPRN columns, all **confirmed present** in the combiner output (`address2uprn_uprn`, `address2uprn_address`, `address2uprn_lexiscore`, `Internal Reference`, `Address 1/2/3`, `postcode`). The raw `Walls`/`Roofs`/`Property Type`/`Built Form` cells are **not** in the combiner output — they survive only in (a) the `{uploadId}-classifier.csv` on S3 (original headers) and (b) `landlord_*_overrides` as *resolved* values keyed by description. So v2 population must assemble **four inputs**, not one file: - `property_id` (identity) ← combiner output `(portfolio_id, uprn)` — **but no-UPRN rows have no such key**; - raw cell text ← the classifier CSV (not the combiner output); - cell → building-part split ← `multiEntryOrdering` on `bulk_address_uploads`; - description → `override_value` ← `landlord_*_overrides` (normalized description). - **Two open v2 hazards (entry point for the v2 session):** (1) the join key between classifier CSV and combiner output — is there a stable per-row key (`Internal Reference`?) and is row order preserved through postcode-split + combine? (2) obtaining `property_id` for unmatched (no-UPRN) rows — v1's `onConflictDoNothing` returns no ids, so v2 likely needs `RETURNING id` mapped back to source rows. - **Idempotency / re-run. RESOLVED 2026-06-04 (per-table).** - `property`: keep today's `onConflictDoNothing` on `(portfolio_id, uprn) where uprn is not null` — existing properties are not churned. - `property_overrides`: `onConflictDoUpdate` on `(property_id, override_component, building_part)` — **recalculate** `override_value` + `original_spreadsheet_description` + `updated_at` to the latest resolution, so an existing property whose override changed is refreshed in place. Guard `WHERE source='classifier'` once a per-property user-edit path exists (until then every row is classifier-derived, so blind overwrite is correct). See the "Re-run = recalculate" and "Snapshot, not FK" locked decisions. ## Key code references (from exploration) **Frontend (`assessment-model`):** - [finalize/route.ts](../../src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/finalize/route.ts) — today's synchronous property insert (to be deleted). - [property.ts](../../src/app/db/schema/property.ts) — `property` table (`property_type`, `built_form` columns exist; `uq_property_portfolio_uprn`). - [landlord_overrides.ts](../../src/app/db/schema/landlord_overrides.ts) — the four per-component override tables + all pgEnums (`wallTypeEnum`, `roofTypeEnum`, `propertyTypeEnum`, `builtFormTypeEnum`, `overrideSourceEnum`). - [bulk_address_uploads.ts](../../src/app/db/schema/bulk_address_uploads.ts) — `multiEntryOrdering` (permutations, `0=main`), `multiEntrySummary`, `verifyAck`, `combinedOutputS3Uri`. - [ADR-0004](../adr/0004-multi-entry-building-part-ordering.md) — defers exactly this fact layer; the building-part ordering model. - [ADR-0002](../adr/0002-landlord-override-vocabulary.md) — vocabulary layer. **Backend (`Model`):** - `applications/landlord_description_overrides/handler.py` — the worker pattern to mirror (`subtask_handler`, `TaskOrchestrator`, trigger body, `commit_scope`). - `infrastructure/postgres/landlord_wall_type_override_table.py` — SQLModel mirror pattern for the new `PropertyOverrideRow`. - `infrastructure/postgres/landlord_override_enums.py` — shared `override_source` SAEnum pattern. - `infrastructure/postgres/property_table.py` — `PropertyRow` defensive view ("backend never inserts" — to change). - `repositories/landlord_overrides/landlord_override_repository.py` — repository pattern for the new override repo. - `orchestration/landlord_description_overrides_orchestrator.py` — orchestrator pattern; note it splits cells into an orderless set (discards part order — recovered via `multiEntryOrdering`). - Downstream: `orchestration/property_baseline_orchestrator.py` (re-score-on-override seam), `orchestration/ingestion_orchestrator.py`. ## Docs to update (when this lands) - **CONTEXT.md**: `Property Type` / `built_form` are **per-part-capable**, not whole-dwelling. Add the per-Property fact layer (`property_overrides`) to the glossary + relationships. Possibly a `building_part` index definition. - **New ADR** (frontend) for `property_overrides` + finaliser; companion Model ADR for the cross-repo write, citing ADR-0003/0004.