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
| 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
| 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
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.
setthenmultiply). - Matching.
targetIdnull 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.amountandleads_per_monththe override replaces the base before growth compounding:amt(t) = override(amount) * (1+g)^(t - start), sosetdoes 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: trueonprice/discount_rate, all cohorts of the matched plans are repriced in theeffectiveFrommonth bynetPrice(t) / netPrice(t-1). Repricing only triggers foreffectiveFromafter the start month; an override effective at month 0 already sets the price ofstartingCustomers. - Assumptions. Without
effectiveFrom(or forpayment_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 withint(). WitheffectiveFrom, a monthly rule is added instead. Onlyrefund_rate,employer_overhead_rate,annual_salary_increase_rateandcorporate_tax_rateare read per month by the forecast; a dated override on any other assumption (e.g.runway_burn_window_months) is reported asappliedbut has no effect.vat_rateis not used by the engine at all. - Structural values.
headcount.addandfunding_event.addtake an object with the model's camelCase fields:headcount.add:role,department,startDate(defaults toeffectiveFrom),monthlyCost, optionalemploymentType,endDate,fte,cogsAllocation,id.funding_event.add:name,amount,date(defaults toeffectiveFrom), optionaltype,status,includeInForecast,id.debt.add(used for loans from funding search, ADR 49):name,principal,drawdownDate(defaults toeffectiveFrom),termMonths, optionalannualInterestRate,graceMonths,repayment(amortizingorbullet),id.- Without an id or
targetId, the id isscn_hire_<10 hex>/scn_fund_<10 hex>from a UUIDv5 of the spec, so it is stable across runs. - Date values accept
YYYY-MM-DDorYYYY-MM(first of month). operationis ignored for structural keys exceptfunding_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 anassumption_set_id, the frozenassumption_sets.snapshotis used with onlymeta.asOfMonthreplaced by the current month (clamped to the horizon). Otherwise the live working inputs are built from the planning tables (ModelInputBuilder.build), withmodelVersion = "working@" + first 12 hex of contentHash. Assumption sets getmodelVersion = "assumptions-v<N>". - Create: from
baseScenarioIdthe base's assumption set and overrides are copied (base overrides first, then new ones); otherwise the body'sassumptionSetId, else the latest approved set, else live inputs. Archived sets are rejected. - Update: only
draftscenarios; incrementsversion; overrides are replaced (scenario_overridesrows deleted and re-inserted withposition). - 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;
basekind becomescustom.
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.