docs(adr): run record is an app-created task, not a new table

Amends ADR-0008 after challenge: the Modelling run reuses the BulkUpload
trigger convention (app inserts the tasks row + config subtask with inputs
JSON, passes task_id to the distributor) instead of a dedicated
modelling_run table. No migration; status and progress come from the task
system by construction; dispatch failure marks the task failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-06 16:27:21 +00:00
parent adcc9b8b83
commit c7dfe95e15
2 changed files with 33 additions and 17 deletions

View file

@ -105,7 +105,7 @@ A Scenario constraint that takes a measure off the table for the optimiser. The
_Avoid_: inclusions, allowed measures (as a stored concept — UI may present "allowed/excluded" toggles, but what's captured is the exclusion set)
**Modelling run**:
One user-initiated act of triggering modelling: a set of Scenarios crossed with one filter-defined property selection within a Portfolio. An insert-only record of *what was asked* — the Run filters, the Scenarios, who triggered it, when, and the matched-property count shown at preview — so Plans can be traced back to the selection that produced them. Execution progress is a separate concern (the pipeline's task records own it); a Modelling run is the request and its provenance, not the work. A Scenario is the *question*; a Modelling run *asks* it (possibly again, possibly for a different property selection). Re-running an already-modelled property appends a newer Plan; latest wins on read.
One user-initiated act of triggering modelling: a set of Scenarios crossed with one filter-defined property selection within a Portfolio. A durable record of *what was asked* — the Run filters, the Scenarios, who triggered it, when, and the matched-property count shown at preview — so Plans can be traced back to the selection that produced them, alongside how the work is going. A Scenario is the *question*; a Modelling run *asks* it (possibly again, possibly for a different property selection). Re-running an already-modelled property appends a newer Plan; latest wins on read.
_Avoid_: job, batch, trigger request, modelling task (the pipeline's execution records)
**Run filter**:

View file

@ -23,14 +23,23 @@ explicit marker when trigger wiring lands. This is that wiring.
## Decision
- **A `ModellingRun` is recorded, insert-only, by Next.js at trigger time**:
portfolio, the selected scenario ids, the Run filters, the matched-property
count shown at preview, who triggered it, when. It is the request and its
provenance — Plans become traceable to the selection that produced them.
It carries no status column (mirrors ADR-0003's derived-state rule).
- **The distributor receives `{run_id, portfolio_id, scenario_ids, filters}`**
- **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`)
plus one config `sub_task` whose `inputs` JSON carries
`{scenario_ids, filters, previewed_property_count, triggered_by}`, then
passes `task_id` to the distributor, which attaches its execution subtasks
to the same task. **No new table, no migration.** 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 ("61 of 104 ready") falls out of the existing
subtask-count summary pattern.
- **The distributor receives `{task_id, portfolio_id, scenario_ids, filters}`**
— filters, never property-id lists. The payload stays tiny at any portfolio
size; the run row is the durable copy of the same config.
size; the config subtask is the durable copy of the same request.
- **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
@ -46,12 +55,11 @@ explicit marker when trigger wiring lands. This is that wiring.
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 derived by correlation, not stored**: the distributor stamps
`run_id` on the task records the pipeline already writes
(`tasks`/`sub_tasks`, service `plan_engine`). 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. No live
progress bars in v1.
- **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 subtask-count 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
@ -59,6 +67,13 @@ explicit marker when trigger wiring lands. This is that wiring.
## 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
@ -74,9 +89,10 @@ explicit marker when trigger wiring lands. This is that wiring.
## Consequences
- Two backend asks must land with the distributor: accept + persist `run_id`
into task records, and expose the dry-run/count endpoint sharing the
resolution code path.
- Two backend asks 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), and expose the
dry-run/count endpoint sharing the resolution code path.
- 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