# 3. Scenarios are authored by the app, insert-only, with modelling state derived

Date: 2026-07-04

## Status

Accepted

## Context

Historically a `scenario` row was created **by the modelling backend at trigger
time** — a scenario was born already modelled, with plans attached. The new
portfolio journey (upload properties → create scenarios → trigger modelling)
requires users to author a Scenario *before* any modelling exists. The trigger
API already accepts an optional `scenario_id`, so "author first, model later"
fits the existing contract.

Recent data (last 6 months, n=402) shows the modern Scenario is small: name,
housing type, goal + goal value, measure exclusions, optional per-property
budget. The scenario table's aggregate/result columns (`cost`,
`co2_equivalent_savings`, `cost_per_sap_point`, …) are **legacy**: the new
pipeline populates none of them (0 of 7 new-era rows) — results live on plans
and are computed by Reporting.

## Decision

- **The Next.js app inserts `scenario` rows directly** when a user completes
  the creation journey. The **configuration is immutable** — goal, target,
  housing type, budget and exclusions never change after creation (a change of
  mind is a new Scenario), so modelled results always correspond to the config
  they were generated against. Two narrow mutations are allowed:
  - **Rename** — the name is a label, not configuration; renameable any time.
  - **Delete, only while unmodelled** — permitted iff no plans reference the
    scenario. Exposed on the scenario detail page only: one
    `COUNT(DISTINCT property_id)` scoped to `(portfolio_id, scenario_id)`
    (index-only scan on the plan composite index) both gates deletion and
    powers the blocked-delete message ("There are N properties with plans in
    this scenario — contact a Domna administrator to remove the plans first").
    The gallery's derived status uses one grouped query per portfolio, never
    per-scenario probes. The delete must guard atomically
    (`DELETE ... WHERE NOT EXISTS (SELECT 1 FROM plan WHERE scenario_id = …)`)
    so a modelling run landing plans mid-request cannot orphan them.
- **No status column.** A Scenario's modelling state is *derived*: plans
  referencing `plan.scenario_id` exist → "Modelled"; none → "Awaiting
  modelling". This mirrors the codebase's preference for derived signals
  (provenance) and cannot drift from reality.
- App-authored rows set `multi_plan = true` (universal in modern data),
  `is_default = false` (the backend owns default-scenario semantics at
  modelling time), and never populate the legacy aggregate columns.
- The backend keeps writing its own scenario rows for trigger paths that don't
  pass a `scenario_id` (existing upload modal) — **two writers, one table**,
  distinguishable by plan existence, not provenance flags.

## Alternatives considered

- **Separate `scenario_draft` table**: keeps the "scenario rows imply results"
  invariant, but forces a copy-on-trigger dance, splits one concept across two
  tables, and the invariant is already dead — new-era rows carry no results.
- **Stored status column**: explicit lifecycle, but a second source of truth
  that drifts (re-runs, deleted plans) and needs backfilling for 1,200+
  existing rows.
- **Backend-owned creation via a new FastAPI endpoint**: preserves single
  writer, but couples a pure-CRUD authoring flow to backend availability and
  a cross-service deploy for a UI feature.

## Consequences

- Any consumer that assumes "scenario ⇒ has plans/results" must now tolerate
  plan-less Scenarios. Known today: Reporting's scenario selector (should hide
  or mark unmodelled Scenarios — its metrics endpoint returns zeros for them).
- The future trigger-wiring step passes `scenario_id` and inherits authored
  config; it must not create a duplicate scenario row for that path.
- The in-flight "modelling running" state is not representable by derivation;
  when trigger wiring lands it needs an explicit marker (e.g.
  `last_triggered_at`), recorded here as a known follow-up, not smuggled in
  now.
