# 8. Modelling runs pass filters to the distributor; the backend owns resolution

Date: 2026-07-06

## Status

Accepted

Numbering note: 0004–0006 are referenced by CONTEXT.md but were lost to the old
`docs/adr/**` gitignore rule; 0008 avoids all collisions (0003 = app-authored
scenarios, 0007 = postcode search).

## Context

Users can now add properties (postcode search, bulk upload) and author
Scenarios (ADR-0003) before any modelling exists. The missing step is
triggering modelling for *chosen* properties: many Scenarios × a subset of the
portfolio, potentially 50,000 properties. The entry point on the Model side is
a **distributor** lambda — it fans work out to modelling workers, it does not
model synchronously. ADR-0003 recorded a known follow-up: the in-flight
"modelling running" state is not representable by derivation and needs an
explicit marker when trigger wiring lands. This is that wiring.

## Decision

- **A Modelling run is recorded as an app-created task** — the BulkUpload
  trigger convention (`triggerAddressMatching`): Next.js inserts the `tasks`
  row (service `modelling_run`, `source_id` = portfolio, status `waiting`)
  whose **`inputs` column** (added for this — mirrors `sub_task.inputs`)
  carries `{portfolio_id, scenario_ids, filters, previewed_property_count,
  triggered_by}` as JSON, then passes `task_id` to the distributor, which
  attaches its execution sub-tasks to the same task. **One new column, no new
  table.** Run history and the per-scenario in-flight badge read the
  portfolio's own `modelling_run` tasks (portfolio-scoped, a handful of rows)
  and parse the app-authored inputs JSON; execution progress falls out of the
  existing subtask-count summary pattern. Sub-task granularity is the
  distributor's **batches**, so progress is shown as a percentage — batch
  counts are internally consistent but never comparable to property or plan
  numbers.
- **The distributor (`POST /v1/modelling/trigger-run`) receives
  `{task_id, portfolio_id, scenario_ids, filters}`** — filters, never
  property-id lists. The payload stays tiny at any portfolio size; the task's
  `inputs` is the durable copy of the same request (plus provenance the
  distributor doesn't need).
- **Dispatch failure marks the task `failed`** (an improvement on the
  bulk-upload flow, which leaves failed triggers at `waiting`), so history
  never shows a ghost run as pending.
- **The modelling backend is the single authority for resolving filters to
  properties.** Resolution precedence: landlord-override fact → EPC-derived
  value (new-approach EPC graph, lodged over predicted, or legacy property-row
  columns) → **Unknown**. The app never re-implements this rule.
- **Preview counts are computed in-app** (amended 2026-07-06; originally a
  backend dry-run endpoint): one grouped SQL statement resolves the filters —
  override snapshot → EPC-derived (`propertyTypeSql`/`builtFormTypeSql` in
  epcSources: new-approach graph, lodged over predicted, RdSAP codes mapped;
  legacy property-row columns) → Unknown — and intersects the matched set
  with `plan` for per-scenario already-modelled counts ("120 of 214 will be
  re-modelled"). Both the app and the distributor read this database and
  implement this documented rule; divergence is a bug in whichever side moved
  without the other. Plan arithmetic is exact: one plan per
  (scenario, property) per run.
- **Filter options are resolution-free in the app**: the postcode multi-select
  is one `SELECT DISTINCT postcode` per portfolio; type/built-form options are
  the static canonical enums plus Unknown. Filters are bounded (cap the
  postcode multi-select) as a UX guard; "all" is an absent filter, never an
  enumeration.
- **Run status is the task's status** — no correlation machinery needed: the
  run and the execution share one record by construction. Scenario badges
  gain a third derived state — "Modelling in progress" — and a per-portfolio
  run history lists each run's filters, counts, initiator and derived status
  with batch-based percentage progress.
- **Concurrent runs warn, never block**: triggering a scenario with a run in
  flight shows who started it and when, but proceeds. Plans append and
  latest-per-property wins on read, so overlap is safe; a hard lock could leak
  on a dead run.

## Alternatives considered

- **A dedicated `modelling_run` table (+ scenarios join table)**: relational
  scenario linkage and typed columns for who/filters/counts, but a migration,
  a second record system beside tasks, and correlation machinery (either the
  backend stamping run ids into tasks, or the app storing returned task ids).
  The task system already records app-initiated pipeline work with inputs,
  progress and status — and the badge/history queries are portfolio-scoped
  over a handful of rows, so the JSON parse costs nothing that matters.
- **Pass resolved property ids to the distributor**: makes preview-vs-run
  drift impossible by construction, but ~50k ids is a payload the async entry
  point can't take, and it moves the resolution rule into the app — the
  backend already owns it for modelling itself.
- **App-side count queries mirroring the resolution rule**: no backend
  dependency for the preview, but the overrides-else-EPC rule would live in
  two codebases and the shown count could disagree with what runs.
- **Status columns on the run row (two-writer, as bulk uploads)**: simple
  reads, but a second source of truth that can silently never arrive; task
  correlation reuses records the pipeline writes anyway.
- **Blocking concurrent runs**: prevents accidental double-spend but a stalled
  pipeline would lock the scenario until someone clears task rows by hand.

## Consequences

- One backend ask must land with the distributor: accept an app-created
  `task_id` and attach execution subtasks to it (rather than creating its own
  task, as the current `plan_engine` entrypoint does). Its filter resolution
  must match the rule above — the app's preview is the reference
  implementation.
- The preview's already-modelled callout and matched counts are only as fresh
  as trigger time; a property added between preview and trigger is picked up
  by the run (filters re-resolve) — the run row records the previewed count so
  discrepancies are visible after the fact.
- Reporting/scenario UIs gain a third derived scenario state; anything
  assuming "has a run row ⇒ has plans" is wrong (a dead run may produce none).
- The tagging system (future) extends Run filters without touching the
  contract shape: tags become one more optional filter key.
- Known follow-up: **email notification on run completion**. The dispatch
  confirmation sets a time expectation only; "we'll email you when results
  are ready" was deliberately kept out of the UI copy until a completion
  notification exists. The run_id → task correlation gives the completion
  signal to hang it on.
