# Scenario overrides

> A scenario is the plan's base inputs plus an ordered list of changes ("overrides"), such as "churn 20 % higher from July" or "hire a second engineer in March". Overrides never modify the stored inputs; the engine applies them to a copy for each run, so the base plan and every scenario can be compared side by side. This page gives the formulas for how an override changes a value, what each symbol means, when they hold and a worked example, followed by the catalogue of what can be overridden.

A scenario is the plan's base inputs plus an ordered list of changes ("overrides"), such as "churn 20 % higher
from July" or "hire a second engineer in March". Overrides never modify the stored inputs; the engine applies them
to a copy for each run, so the base plan and every scenario can be compared side by side. This page gives the
formulas for how an override changes a value, what each symbol means, when they hold and a worked example, followed
by the catalogue of what can be overridden.

**How to read this page.** Each formula is shown on its own line, followed by its symbols, the conditions under
which it holds, what it says in plain words and a short example.

## How an override changes a value

### Set, multiply, add

$$\begin{gathered} v'(t) = \begin{cases} \text{value} & \text{set} \\ v(t) \cdot \text{value} & \text{multiply} \\ v(t) + \text{value} & \text{add} \end{cases} \\[8pt] \text{for } t \ge t_0, \\[8pt] v'(t) = v(t) \text{ for } t < t_0 \end{gathered}$$

| Symbol | Meaning | Unit |
| --- | --- | --- |
| $v(t)$ | The value in month $t$ before this override (the base input, or the result of earlier overrides) | the parameter's unit |
| $v'(t)$ | The value after this override | the parameter's unit |
| value | The override's `value` | the parameter's unit, or a factor for multiply |
| $t_0$ | Month index of `effectiveFrom` (0 when empty or before the model start) | month |

**In plain words.** An override replaces a value, scales it or adds to it, from a given month on. Earlier months
keep the base value.

**Valid when.** The key is numeric and matches at least one entity (otherwise it is reported as ignored). Several
overrides on the same field stack in list order: each one starts from the result of the previous one.

**Example.** Base churn 3 %. A `multiply` by 1.2 from month 6, then an `add` of 0.005 from month 9: churn is 3 % in
months 0 to 5, $0.03 \times 1.2 = 3.6\,\%$ in months 6 to 8 and $3.6\,\% + 0.5\,\% = 4.1\,\%$ from month 9.

### Overrides on amounts that grow

$$\text{amt}(t) = v'\,(1 + g)^{\,t - s}$$

| Symbol | Meaning | Unit |
| --- | --- | --- |
| $v'$ | The overridden base amount (`amount` of a cost driver, or `leadsPerMonth`) | currency, or leads per month |
| $g$ | The item's monthly growth rate | share per month |
| $s$ | The item's start month | month |

**In plain words.** For cost amounts and leads, the override changes the starting amount, and growth still applies
on top. So `set` to 2,000 does not pin the amount at 2,000 when the item grows.

**Example.** A cost of €1,000 growing 2 % a month from month 0, overridden with `set` 2,000 from month 6: in month 6
it is $2{,}000 \times 1.02^6 = 2{,}252.32$ euros, not 2,000.

### Repricing existing customers

$$\text{ARPA}_{\text{cohort}}(t_0) \leftarrow \text{ARPA}_{\text{cohort}}(t_0 - 1) \cdot \frac{\text{netPrice}(t_0)}{\text{netPrice}(t_0 - 1)}$$

**In plain words.** Normally customers keep the price they signed up at, and a price change affects only new
customers. With `applyToExisting: true` on `price` or `discount_rate`, every cohort of the matched plans moves by the
same percentage as the price, in the month the change takes effect.

**Valid when.** $t_0 > 0$. An override effective in month 0 already sets the price of the starting customers, so
there is nothing to reprice.

**Example.** The price goes from €100 to €110 in month 6 with `applyToExisting`: a cohort paying €90 after a
discount pays $90 \times 1.1 = 99$ euros from month 6.

## Override shape

| Field (JSON) | Type | Meaning |
|---|---|---|
| `assumptionKey` | string | Key from the catalogue below, or `assumption.<name>` |
| `operation` | `set` \| `multiply` \| `add` | Default `set`. `set: v = value`, `multiply: v = base * value`, `add: v = base + value` |
| `value` | any JSON | Number (or numeric string) for numeric keys; date/month string or object for structural keys |
| `targetId` | string \| null | Restrict to one entity id; null = all matching entities |
| `effectiveFrom` | date \| null | First month the change applies (month index, clamped to >= 0); null = from month 0 |
| `applyToExisting` | bool | Price/discount only: also reprice existing customers (default false) |
| `note` | string | Free text, not used in calculation |

## Catalogue

Numeric (time-varying) keys. Rules are resolved per month by `OverrideBook.value(kind, field, base, t, entityId, category, employmentType)`.

| assumptionKey (aliases) | Target | Filter |
|---|---|---|
| `price`, `price_plan.list_price_per_month` | price plan `listPricePerMonth` | |
| `discount_rate` | price plan `discountRate` | |
| `one_time_fee`, `usage_price_per_unit`, `usage_units_per_customer`, `new_customer_mix` | price plan field of same name | |
| `monthly_churn_rate`, `churn` | price plan `monthlyChurnRate` | |
| `monthly_expansion_rate` | price plan `monthlyExpansionRate` | |
| `conversion_rate`, `leads_per_month`, `monthly_lead_growth_rate` | revenue driver field | |
| `cost_driver.amount` | cost driver `amount` | any category |
| `api_cost_per_customer`, `ai_api_cost` | cost driver `amount` | category `ai_api` |
| `cloud_cost` / `marketing_spend` / `software_cost` / `contractor_cost` | cost driver `amount` | `cloud_hosting` / `marketing` / `software` / `contractors` |
| `salary_cost`, `headcount.monthly_cost` | headcount `monthlyCost` | |
| `contractor_fee` | headcount `monthlyCost` | `employmentType = contractor` |

Structural keys (applied once to a deep copy of the input before the run):

| assumptionKey | Effect | `targetId` |
|---|---|---|
| `headcount.add` | Appends a `HeadcountPlan` built from `value` (object) | optional, used as the new id |
| `hiring_start_date`, `headcount.start_date` | Sets `startDate` to `value` | required |
| `headcount.end_date` | Sets `endDate` to `value` | optional (null = **all** hires) |
| `headcount.remove` | With `effectiveFrom`: `endDate = effectiveFrom - 1 day`; without: entry removed | required |
| `funding_event.add` | Appends a `FundingEvent` built from `value` | optional, used as the new id |
| `debt.add` | Appends a `DebtInstrument` (a loan with interest and repayment) built from `value` | optional, used as the new id |
| `funding_event.amount` | `amount = apply(operation, amount, value)` | optional (null = all events) |
| `funding_event.date` | Sets `date` to `value` | optional (null = all events) |
| `funding_event.remove` | Sets `includeInForecast = false` | optional (null = all events) |

Assumption keys: `payment_delay_days`, `payables_delay_days`, `refund_rate`, `employer_overhead_rate`, `corporate_tax_rate`, `minimum_cash_buffer`, plus any `assumption.<name>` (camelCase is converted to snake_case, e.g. `assumption.annualSalaryIncreaseRate`).

## Semantics

- **Order.** Overrides are processed in list order. Several numeric rules on the same field stack in that order (e.g. `set` then `multiply`).
- **Matching.** `targetId` null applies to every entity of the target kind that passes the category/employment-type filter. A numeric override with no matching entity is ignored.
- **Effective month.** A numeric rule applies for `t >= monthIndex(effectiveFrom)`; dates before model start mean month 0.
- **Base values with growth.** For `cost_driver.amount` and `leads_per_month` the override replaces the base before growth compounding: `amt(t) = override(amount) * (1+g)^(t - start)`, so `set` does not pin the month's amount when growth is non-zero.
- **Prices and existing customers.** Customer cohorts keep the net price of their signup month. With `applyToExisting: true` on `price`/`discount_rate`, all cohorts of the matched plans are repriced in the `effectiveFrom` month by `netPrice(t) / netPrice(t-1)`. Repricing only triggers for `effectiveFrom` after the start month; an override effective at month 0 already sets the price of `startingCustomers`.
- **Assumptions.** Without `effectiveFrom` (or for `payment_delay_days`, `payables_delay_days`, `minimum_cash_buffer`, which are read once per run) the new value replaces the assumption for the whole run; integer fields are truncated with `int()`. With `effectiveFrom`, a monthly rule is added instead. Only `refund_rate`, `employer_overhead_rate`, `annual_salary_increase_rate` and `corporate_tax_rate` are read per month by the forecast; a dated override on any other assumption (e.g. `runway_burn_window_months`) is reported as `applied` but has no effect. `vat_rate` is not used by the engine at all.
- **Structural values.** `headcount.add` and `funding_event.add` take an object with the model's camelCase fields:
  - `headcount.add`: `role`, `department`, `startDate` (defaults to `effectiveFrom`), `monthlyCost`, optional `employmentType`, `endDate`, `fte`, `cogsAllocation`, `id`.
  - `funding_event.add`: `name`, `amount`, `date` (defaults to `effectiveFrom`), optional `type`, `status`, `includeInForecast`, `id`.
  - `debt.add` (used for loans from funding search, [ADR 49](../adr/0049-funding-search-with-eligibility-formulas.md)): `name`, `principal`, `drawdownDate` (defaults to `effectiveFrom`), `termMonths`, optional `annualInterestRate`, `graceMonths`, `repayment` (`amortizing` or `bullet`), `id`.
  - Without an id or `targetId`, the id is `scn_hire_<10 hex>` / `scn_fund_<10 hex>` from a UUIDv5 of the spec, so it is stable across runs.
  - Date values accept `YYYY-MM-DD` or `YYYY-MM` (first of month).
  - `operation` is ignored for structural keys except `funding_event.amount`.

## Validation results

`apply_overrides` returns one entry per override: `{override, status, message, matchedIds}` with `status` `applied` or `ignored`.

| Message | Cause |
|---|---|
| `unknown assumptionKey '<key>'` | Key not in catalogue and not `assumption.*` |
| `unknown assumption '<field>'` | `assumption.<name>` that is not an `Assumptions` field |
| `no matching entity for override` | Numeric key matched no entity |
| `invalid override value: <error>` | `ValueError`/`TypeError`/`KeyError`, e.g. non-numeric value, `no matching headcount entry`, `no matching funding event`, `targetId is required for this headcount override`, or a model validation error from `headcount.add`/`funding_event.add` |
| `structural change applied`, `<field> -> <value>`, `<n> entities affected` | Applied |

`POST /v1/forecasts` still runs when some overrides are ignored; they are listed in `appliedOverrides`. `POST /v1/overrides/validate` returns `valid = all applied`.

Control plane (`PlanningService.normalizeOverrides`, `validateOverrides`): `assumptionKey` is required and `operation` must be `set|multiply|add` (400 otherwise); blank `targetId` becomes null; `applyToExisting` defaults to false. On scenario create/update the overrides are validated by the engine against the scenario's input, and any `ignored` result rejects the request with 422 "Some scenario overrides cannot be applied". Preview runs and `runScenario` do not re-validate.

## Scenario lifecycle (control plane)

`PlanningService`:

- **Base inputs** (`scenarioInput`): if the scenario has an `assumption_set_id`, the frozen `assumption_sets.snapshot` is used with only `meta.asOfMonth` replaced by the current month (clamped to the horizon). Otherwise the live working inputs are built from the planning tables (`ModelInputBuilder.build`), with `modelVersion = "working@" + first 12 hex of contentHash`. Assumption sets get `modelVersion = "assumptions-v<N>"`.
- **Create**: from `baseScenarioId` the base's assumption set and overrides are **copied** (base overrides first, then new ones); otherwise the body's `assumptionSetId`, else the latest approved set, else live inputs. Archived sets are rejected.
- **Update**: only `draft` scenarios; increments `version`; overrides are replaced (`scenario_overrides` rows deleted and re-inserted with `position`).
- **Approve**: requires an approved assumption set; a scenario on live inputs freezes them into a new set, approved in the same transaction. Approved and archived scenarios are immutable (enforced in code, not in the DB).
- **Duplicate**: new draft with the same set and overrides; `base` kind becomes `custom`.

## Run snapshot and hashes

`PlanningService.executeRun` sends `{input, scenario: {id, name, kind}, overrides}` to `POST /v1/forecasts` and stores:

| Table.column | Content |
|---|---|
| `forecast_runs.scenario_id`, `scenario_version`, `scenario_name`, `scenario_kind` | Scenario at run time (null id for previews) |
| `assumption_set_id` | Pinned set, or null for live inputs |
| `run_kind` | `scenario` or `preview` |
| `model_version` | `result.modelVersion` (= `meta.modelVersion`) |
| `engine_version` | `result.engineVersion` |
| `input_snapshot` | The full engine request (input + scenario ref + overrides) |
| `input_hash` | `result.inputHash`, computed by the engine |
| `summary` | `result.summary` plus `warnings` and `appliedOverrides` |
| `calculated_at` | Engine timestamp (UTC) |
| `source`, `created_by`, `created_at` | Actor and insert time |
| `forecast_results.result` | The complete `ForecastResult` JSON (series, lines, summary, effective input) |

A `forecast.run` audit event records scenario id/name, model and engine version, input hash, overrides, `runwayMonths` and `minimumCash`. Runs are not updated afterwards except for the plan-baseline columns (`is_plan_baseline`, `plan_approved_by`, `plan_approved_at`) set by `markPlanBaseline`, which only accepts runs of approved scenarios.

Two different hashes exist:

| Hash | Computed by | Over | Excludes |
|---|---|---|---|
| `forecast_runs.input_hash` | engine `forecast.input_hash` | Pydantic-normalised request (defaults filled, decimals as strings, camelCase), `json.dumps(sort_keys=True, separators=(",", ":"))`, SHA-256 | nothing in the request; `calculatedAt` is not part of the request |
| `assumption_sets.snapshot_hash` and working-status hash | control plane `ModelInputBuilder.contentHash` | Snapshot JSON with sorted keys (`core/Json.kt canonical`), SHA-256 | `meta.modelVersion`, `meta.asOfMonth` |

Consequences: the run hash changes with the scenario name/kind and with `asOfMonth`, which moves with the calendar, so re-running an unchanged pinned scenario in a later month gives a different `input_hash`. The two hashes are not comparable with each other.

## For developers

Sources: `apps/finance-engine/src/finance_engine/calc/overrides.py` (`CATALOGUE`, `resolve_target`, `apply_overrides`, `OverrideBook`, `_apply_structural`), `models/inputs.py` (`ScenarioOverride`), and in the control plane `model/Planning.kt` (`PlanningService`) and `model/ModelInputBuilder.kt`. Scenario and run persistence lives in `model/Planning.kt`. Keep this page in sync with any change there.
