Skip to main content
CFDL

This is a specification page.

It defines CFDL normatively — complete, exact, and written for people implementing against it. If you are building a model, Reference covers the same ground at the altitude of the work.

Status: Draft

Purpose: CFDL (Cash Flow Domain Language) is a proprietary, human-readable DSL for defining cash-flow models across asset classes. A CFDL model compiles deterministically to a canonical JSON IR used by valuation engines (deterministic DCF, Monte Carlo, scenarios, risk/metrics).

Normative keywords

The key words MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY and OPTIONAL in this document are to be interpreted as described in BCP 14 (RFC 2119, RFC 8174) when, and only when, they appear in all capitals.

This specification exists so that a second implementation can be written from it. That is the reason the distinction is stated rather than assumed: a reader has to be able to tell a requirement from advice without inferring it from the surrounding sentence.


1. Design goals

1.1 Non-negotiables

  1. Human-readable, file-based modeling: models are composed of *.cfdl files.
  2. Deterministic compilation: the same inputs produce the same canonical IR (subject to explicitly controlled seeds for stochastic runs).
  3. Separation of concerns: Time, Structure, and Behavior are separate concepts in the language.
  4. Contracts are first-class: all domain concepts can be represented as a contract with terms and effects.
  5. Streams are first-class: cash flows are represented as streams (time-indexed vectors) attached to an owning entity.
  6. Events are first-class: discrete time-step events can mutate entity state and activate/deactivate behaviors.
  7. Strong typing: core types (Money, Date, Frequency, Rate, etc.) are defined and validated.
  8. Multi-currency: Money values include currency; conversions are explicit in expressions.
  9. Single canonical IR: compiler output is one JSON IR format used by all engines.
  10. No correlations in core: correlation is not a core-language concept and is not present in the IR.

1.2 What v0.1 intentionally excludes

  • Domain Pack specifications (defined separately)
  • Correlated sampling declarations
  • Optimization policies for options (beyond deterministic triggers)
  • Comprehensive relationship constraints/ontology reasoning beyond type validation

2. Model lifecycle and artifacts

2.1 Compilation pipeline (normative)

  1. Parse: *.cfdl sources → AST
  2. Resolve: imports + symbol table (entities, contracts, streams)
  3. Validate: type-check + schedule/time bounds + required fields
  4. Lower: contract terms + effects → canonical IR objects (preserving provenance)
  5. Emit: canonical JSON IR

2.2 Execution pipeline (informative)

  • Engines consume IR and produce Results JSON.
  • Deterministic runs produce exact cashflows and metrics.
  • Monte Carlo runs sample distributions declared in CFDL assumptions with explicit seeds.

3. Files, modules, and imports

3.1 File extension

  • CFDL source files MUST use .cfdl.

3.2 Entry module

  • A model directory MUST contain an entry file named model.cfdl.

3.3 Imports

Syntax:

import "relative/path/to/file.cfdl"
import "contracts/lease.cfdl" as lease

Rules:

  • Import paths are relative to the importing file.
  • as <name> is accepted and binds nothing. An imported file's declarations join the one merged module under their own names: a stream tower.fee in fees.cfdl imported as fees is tower.fee, never fees.tower.fee, and no expression reads the alias. Name a file's declarations by their own qualified names.
  • Import cycles are forbidden.
  • The compiler MUST establish a deterministic import order (e.g., lexical by resolved absolute path) and treat the model as a single merged module.

3.4 Domain pack selection

Syntax:

use pack "cre" version "0.1.0"

Rules:

  • use pack MAY appear in model.cfdl only.
  • If multiple use pack statements exist, compilation MUST fail.
  • A pack MAY:
    • define/extend the ontology type registry
    • define alias mappings
    • define validators for contract terms/types
    • define contract lowering rules
    • (reserved) define helper expression sugar that expands to core primitives — not in v0.1: the expression function vocabulary is engine-owned and fixed (see Pack Interface §6.7); packs compose the existing primitives

Core language semantics MUST NOT change based on pack selection.


4. Lexical conventions

4.1 Identifiers

  • Identifiers match: [A-Za-z_][A-Za-z0-9_]*
  • Qualified identifiers (Type IDs, namespaces) use dot notation: A.B.C
  • Dot (.) is structural: it separates hierarchical name segments.
  • Underscore (_) is lexical: it is allowed within a segment but does not create hierarchy.

4.2 Literals

  • String: "..." with escapes.
  • Integer: 123, allow _ separators (8_500_000).
  • Decimal: 0.035, allow _ separators.
  • Boolean: true / false.

4.3 Dates

  • Date literals MUST be ISO-like:
    • YYYY-MM-DD (date)
    • YYYY-MM is permitted where a period start is unambiguous (see Time normalization).

4.4 Comments

  • Line comment: // ...
  • Block comment: /* ... */

5. Core type system (strong typing)

5.1 Primitive types

  • String, Bool, Int, Decimal

5.2 Temporal types

  • Date (calendar date)
  • DateRange (start..end, inclusive of start, inclusive of end unless otherwise stated)
  • Frequency (daily, weekly, monthly, quarterly, annual) — a SCHEDULE may take any of the five (schedule every week is valid); a model CALENDAR takes four, and not weekly (§7.1)
  • Duration (e.g., 30d, 12m, 30y)

5.3 Financial types

  • Currency (ISO 4217 string, e.g., USD)
  • Money = { amount: Decimal, currency: Currency }
  • Rate (unitless decimal with semantic meaning; no domain)
  • Fraction (a share of a whole — a probability, a pro rata, a percentage of revenue; 0 to 1 by definition, §5.7)
  • Percent (syntactic sugar for Rate; 10% == 0.10)

5.4 Reference types

  • EntityRef (reference to an entity symbol)
  • ContractRef (reference to a contract instance name)
  • StreamRef (reference to a stream instance name)

5.5 Type checking rules (normative)

  • Any amount that represents money MUST have a currency, either explicitly or inferred from surrounding context.
  • Streams MUST have a declared currency.
  • Expressions MUST type-check to the required slot type.

5.7 Domains (normative)

A type may carry a domain — the range it admits by definition, not by any deal's judgment:

typedomain
Fraction0 to 1, inclusive
Durationa whole number, 0 or more
Inta whole number
Rate, Decimal, Moneynone

A value in a typed slot outside its domain is refused wherever the value arrives: a literal at compile time (E2307), a value the run supplies at run start, a Monte Carlo draw per trial (E5041). Refused, never adjusted — a value pulled into range would be a number the model did not state. A modeler's own range on top of the domain is within (§12.1). A pack field's unit names one of these types where one applies (docs/07 §6.1), so the domain check reaches every contract term without the pack declaring a bound.


5.6 Model currency

A model declares the currency it reports in:

model "solar-portfolio" currency INR

Rules:

  • The code is an ISO 4217 identifier. When omitted, the model reports in USD.
  • Every metric — model.npv, model.total, entity and domain metrics — is denominated in this currency.
  • A stream MUST declare the same currency. Cash flows are summed period by period, so a stream in another currency would be added as though it were the same unit; the compiler rejects the mismatch (E2107_STREAM_CURRENCY_MISMATCH) rather than produce a meaningless total.
  • Cross-currency models require an explicit conversion in the amount expression. The language does not apply FX rates implicitly.

6. Time model

6.1 Master timeline

Syntax:

time calendar monthly from 2026-01-01 for 120
time calendar monthly from 2026-01-01 for 120 project 12

Rules:

  • calendar MUST be one of: daily, monthly, quarterly, annual.
  • from MUST be a Date.
  • for MUST be a positive integer count of periods: the cash horizon.
  • project <n> is OPTIONAL: n more periods after the horizon, the projection tail. Streams are evaluated over the horizon and the tail, so a valuation may fold the income after the hold (a sale struck on the next year's NOI); the tail is excluded from cash results, totals and NPV, and a schedule may reach into it (E2103 bounds a schedule by the horizon plus the tail).
  • The compiler MUST derive the sequence of time points t[0..N-1], and the tail's after it.

6.2 Date normalization

  • If YYYY-MM is used, it MUST be normalized to the first day of the month (YYYY-MM-01).
  • All schedules MUST ultimately resolve to occurrences that align to the master timeline’s grain or be mapped deterministically (see schedule resolution rules).

6.3 Phases

Syntax:

phase construction from 2026-01-01 to 2026-12-31
phase perm from 2027-01-01 to 2031-12-31

Rules:

  • Phase date ranges MUST fall within the master timeline.
  • Phases are named ranges used for organization, scoping, and schedule helpers.

6.4 Phase boundary helpers

Three helpers resolve in SCHEDULE position (see §11):

  • phase_start("name")
  • phase_end("name")
  • phase_enter("name") (an instant)

They are not expression functions. An expression reads the phase it is in as time.phase, the name of the phase covering the current period, so a guard gates on a phase with active when time.phase == "operations" and an event fires on entering one with when time.phase == "operations" — once per entry, on the rising edge (§13.1). A phase entered once yields one occurrence because the phase occurs once, not because the event is latched.


7. Entities (Structure)

7.1 Entity declaration

Syntax (v0.1 core):

entity asset sunset

Rules:

  • Declaration form: entity <family> <name> — two bare identifiers — optionally followed by : <Type> and a block.
  • References use the qualified dotted form: asset.sunset (e.g. on entity asset.sunset).
entity asset tower : CRE.Asset.RealProperty {
  asset_class = "office"
  state stabilized
}

entity asset suite_a : CRE.Asset.Unit {
  rentable_area = 10000
  part of asset.tower
  state leased
}

entity party acme : CRE.Party.Tenant { name = "Acme Corp" }
  • The family is the first identifier: asset for something that produces or consumes cash, party for someone who contracts, owns, lends or invests, container for a grouping that scopes cash — a fund, a portfolio, an SPV, a transaction — and reference for a market path the model computes (below). Containment reuses part of: a container parent's aggregate is folded from its members by the relation, exactly as an asset parent's is, and counts as cash nowhere. Cash MAY attach directly to a container — a land purchase, a development budget, a fund-level fee is deal-level cash, and it is real cash, aggregated with the members'.
  • The family word is checked: one the language lacks is refused where it is written (E1319), and one the type disagrees with is refused naming both (E1323) — entity asset acme : Party is an error. A type's family is the root of its refinement chain (Pack Interface §6.1), so the word carries nothing the type does not; it says what kind of thing is being declared before any type resolves, and it is all an untyped entity states. The results publish the family the compiler resolved, not the symbol's first segment.
  • A reference is the owner of a path the model walks forward — an index compounded off a growth assumption, a rate built from a base and a spread — so the series has a name that says what it is about, rather than sitting as a field on whichever asset first read it. It has fields and a recurrence and nothing else:
assume cpi_growth : rate = 0.025

entity reference cpi : Reference {
  index init 1.0 next prev * (1 + inputs.cpi_growth / time.ppy)
}

A stream reads reference.cpi.index at its period; a field's rule reads it one period back as prev.reference.cpi.index. The results publish the field under its own key as a non-cash series, and the entity appears in the graph with family reference, no net_cash_flow and no total. The base type Reference names three optional fields, level, index and value, and a model may declare others; a pack refines the type when it wants a unit on one. A reference carries no cash: an account, a part of in either direction, a lifecycle, a stream or a contract written on it, a contract or a waterfall step naming it, and a reference with no rule-bearing field are each refused where written, naming the clause (E1388). The stated path — values from a publisher, with a source — is not this entity but an assumption (§12.1); the reference is for the path the model computes from one.

  • The type is checked against the active ontology. With no pack active a model still has the language's own vocabulary — Asset.Real, Asset.Financial, Asset.Intangible, Party, Reference, Container.Fund, Container.Portfolio, Container.SPV, Container.Transaction — because an ontology is a language capability that packs supply defaults for, not one they own. A pack's types are added to those and cannot remove them. A pack type states the master it specializes (refines, Pack Interface §6.1), so "is a" is a recorded fact rather than a naming convention.
  • Attribute values are literals, checked against the type's declared fields.
  • The literal field id is a stable identity: an opaque string a layer above the model assigns to the real-world thing this entity refers to. The engine never interprets it — it is validated for uniqueness within the model (E1360) and republished in the results graph, so a consumer can join a package's numbers to canonical things instead of to symbol names.
  • part of declares hierarchy and is always optional, at every grain. A pool models collective behavior perfectly well with no loans under it; a building needs no units. The modeler chooses the grain and the language does not prefer one. Where a parent does have children, its cash is aggregated from them by the relation, not by a name prefix — published as entity.<symbol>.net_cash_flow.
  • An untyped entity remains legal, so a model written before types existed still compiles. The type is what unlocks the checks, not a condition of being an entity.

7.2 Entity state

  • An entity's type MAY declare a lifecycle: a closed set of states with a mandatory initial one. An entity with a lifecycle is ALWAYS in exactly one of its states, from period 0 — there is no null state and no undeclared state.
  • Events move it:
event refinance when time.t >= 12 {
  set entity asset.senior.status = "refinanced"
}

Rules:

  • Entity state is the primary mechanism used to trigger/activate contractual behavior. A stream may test it directly with active in state <name>, which checks the name against the owner's declared lifecycle — a string comparison cannot be checked, and entity.status == "leasd" is simply false forever.
  • Every write is published in deterministic.transitions, so a transition is observable and assertable rather than inferred from the cash.

7.3 The lifecycle machine

A lifecycle is a finite state machine, and the machine is a core-language construct: a model declares one with no pack at all, and a pack declares the same machine for its domain types in types.toml — the core has the full functionality, and packs tailor it to domains.

lifecycle unit {
  initial vacant
  state vacant, leased, downtime

  vacant   -> leased    when time.t >= 1
  leased   -> leased    when time.t == 6
  leased   -> downtime  when series_sum("core.rent", time.t - 1, time.t - 1) < 50
  downtime -> leased
}

An entity binds a model-declared machine by name — lifecycle unit in its block, and a contract in its body (§8.6) — and an entity whose ontology type declares a lifecycle needs no binding (declaring both is an error: one machine per entity). lifecycle and initial are contextual identifiers, not reserved words.

Rules:

  • States are enumerated ahead of time; edges are not. The finite set is what makes the machine checkable — a misspelled state in an edge is a compile error naming the declared set, not a phantom state. Declaring an edge is what brings it into existence; an undeclared edge does not exist, and absence is the prohibition. A partial machine is a complete machine.
  • Guards live on edges and are evaluated each period the entity is in the edge's from-state — and only then. A guard reads state as the period opened and series strictly backward (at or before the previous period). A time condition is the same construct with a time guard.
  • A guard reads its own subject's fields as entity.<name>. One machine is bound by many entities, so a guard on a pack machine cannot name an instance; entity.months_in_state is the transitioning entity's own field — the same entity root a stream gets for its owner (§16.2), not a namespace of its own. A qualified path (asset.north.months_in_state) still names that one entity. The name resolves against every entity bound to the machine: what each declares, what its type declares, and what a pack derives or lowers onto it. A name none of them carries is a misspelling and is refused at compile (E1131), and in a pack's own guard the pack does not load. A name the type declares but a subject does not carry is a default that subject has declined: the edge is disabled for it, with a warning where it opens in the edge's from-state, and its other edges, a state statement and an event still move it.
  • A guard that cannot be evaluated refuses the run (E5043), as a field's rule does: a condition that failed neither held nor did not. The one reading that is not a failure is a series window lying entirely before the first period — nothing has happened yet, and the guard is false, with no warning (docs/03 §5).
  • A guard may draw. sample(inputs.<name>) in a guard is a draw for the subject in the period the guard is evaluated (§12.2.1), so an edge taken by chance in each period the entity is in its from-state is a hazard.
  • There is no latch. Edge availability is the memory: taking an edge moves the machine and disarms it, and re-entry re-arms it. A covenant that breaches and cures is the topology walked twice.
  • A well-formed machine resolves. A self-edge (leased -> leased) is a real transition — it journals and re-anchors a state-anchored schedule (§11.7). No guard true means the entity holds. When two guards hold in one period, declaration order picks, and at most one transition per entity per period is taken.
  • A guard-less edge is a permission only: an event's write may take it, and the machine never fires it on its own. An event's set … status is validated against the declared relation — refused with the edge named where no edge permits the move — and a machine that declares no edges at all stays unconstrained, which is what every shipped pack machine was.
  • Every transition is journaled with the edge taken and the values its guard read, and published in deterministic.transitions as lifecycle:<id>.

7.3.1 Arrival actions

A transition may carry behavior. Arriving somewhere is an occurrence, and per-arrival bookkeeping — reset a counter, record a shortfall, strike the prevailing rent — belongs on the arrival rather than in a separate event that has to restate the condition.

lifecycle unit {
  initial vacant
  state vacant, leased, downtime

  on enter leased {
    set months_in_state = 0
  }

  vacant   -> leased    when time.t >= 1
  holdover -> leased    when inputs.renews {
    set in_place_rent = prev.in_place_rent * (1 + inputs.bump)
  }
  downtime -> leased    when months_in_state >= inputs.downtime_months {
    set in_place_rent = inputs.market_rent
  }
}

There are two grains because both are real:

  • Entry actions (on enter <state> { … }) carry what is true of the STATE, however it was reached. Resetting months_in_state on entering leased holds for every edge that arrives there, including one added later. This is the primary spelling, and a pack's types.toml machine declares it once for every model using the type.
  • Edge actions (a block after the edge) carry what is true of the PATH taken. A renewal and a re-let both land in leased, but the rent is struck differently because of how you arrived. An entry action cannot express that: it does not know which edge fired.

Rules:

  • Field names are entity-relative. set months_in_state, not set asset.unit_a.months_in_state — one lifecycle is bound by many entities, and the behavior belongs to the entity that transitioned. The field MUST be declared on that entity; an unknown name is a compile error.
  • set writes FIELDS only, never status. An action writing status would fire a second transition inside the same period, breaking one-transition-per-entity-per-period and inviting same-period cascades. A transition that should cause another transition is topology: an edge out of the target state, taken next period. Status writes remain the named event's privilege (§13.1), an option's exercise and a contract's term boundary (§8.5) included, validated against the declared edge relation.
  • set is the whole vocabulary. Stream gating is already declarative (active in state, §9.3) and schedule anchoring already follows entry (state_enter, §11.4); an imperative activate/deactivate on an edge would duplicate a declared pattern. exercise option stays with named events.
  • Actions run on every traversal, whatever took the edge — its own guard, or a named event's status write moving the entity across a permission edge. A status write that moves an entity runs the target state's entry actions exactly as the machine's own path would.
  • Order on arrival is entry actions, then the taken edge's actions, each block in declaration order — the specific refines the general. A same-field write journals the earlier value overridden.
  • Actions evaluate in the guard's environment: state as the period opened, series strictly backward, inputs, time.*, and sample(inputs.<name>) for the arriving entity (§12.2.1). This is the environment the walk already proves acyclic, so actions add no cycle risk. A write settles the field for the period; the recurrence resumes from it next period, and streams later in the same period read the settled value.

7.3.2 Augmenting a pack's machine

A model MAY attach entry and edge actions to a machine its pack declared, additively only. It names the machine by its qualified name — a lifecycle block naming a machine that already exists augments it rather than redeclaring it:

use pack "opco"

lifecycle opco.enterprise {
  on enter acquired {
    set months_held = inputs.prior_hold_months
  }
}

A block naming a machine that does not exist declares a new one, and is then required to carry initial and state as usual.

An augmenting block cannot add or remove states or edges, alter the topology, or remove the pack's actions; the pack's machine stays the checkable contract. The model's actions run after the pack's for the same arrival, so a same-field conflict resolves the model's way and journals the pack's value overridden.

Every action records who wrote it, and the journal names that author. A secondary buyout that carries the sponsor's prior holding period, against a pack whose machine resets the clock on acquired, reads as two journal rows against the same field — one overridden, one applied — each naming its author. Attribution is not optional metadata: an action that cannot say who wrote it is refused, because an unattributable overridden line is the one thing the record exists to prevent.

This is what makes arrival actions an extensibility surface rather than a pack-author convenience: the common position is modeling ON a pack whose machine is right but whose actions stop short.

7.3.3 What runs in a state

A machine MAY say which lines run while its subject is in a state, by the line's role:

lifecycle loan_phase {
  initial drawing
  state drawing, interest_only, amortizing
  in drawing       run proceeds, interest
  in interest_only run interest
  in amortizing    run interest, principal
  drawing       -> interest_only when time.t - state_enter(entity, drawing) >= contract.draw_months
  interest_only -> amortizing    when time.t - state_enter(entity, interest_only) >= contract.interest_only_months
}

Rules:

  • A line is named by its role, the master's line name: the role a pack rule stamps on every line it lowers, or the one a contract's effects stream states with line <role> (§8.3). The machine names no stream and no pack word.
  • A role some state lists runs only in the states that list it, and in <state> run nothing lists none. A role no state lists runs always, so a machine that says nothing about what runs governs nothing.
  • Two machines may govern a line, and every one must allow it: the machine of the contract that produces it (§8.6) and the machine of the entity that owns the stream, the contract's subject. in leased run rent on a unit therefore gates every rent line written on the unit, and a lease's own in paying run rent gates that lease's. A line is paid where both hold.
  • A closing state stops everything (closes, docs/07 §6.1): from the period after the subject enters a state its machine closes, no line owned by it or by anything that is part of it runs inside the cash horizon, whatever the line's role. The projection tail reads the states as the horizon ends. A sale of an asset closes it; the buyer's year a forward price is struck on stays in the tail.
  • A sold asset has its own tail. Where a valuation of the closed node folds past the sale (a sale on forward NOI), its lines are still evaluated for as far as the valuation reaches after the close, as the model-wide tail is: without cash, read by no stream, subtotal, waterfall or loan, and folded by that valuation's figures alone, which therefore price the year the asset would have earned. A model's own metric folds the cash. The asset is evaluated as it stood when it sold: its machines' gates read the sale period's state, and its fields keep their rules. A sale in the horizon's last period needs nothing more, since the model's tail already holds that year. A sale valued on its terms alone (a stated income or a price) or on the income before it evaluates nothing after the close. The asset's own tail is computed by the period walk; a model a forward-reaching guard keeps on the column order is refused (E5038_NEEDS_THE_WALK).
  • A write of the state an entity is already in moves nothing: where the machine declares no self-edge it is journaled as declined, already in that state, and raises no warning.
  • A stream's own active in state and active when still apply, beside what its machines allow. A stream with no role is governed by no machine.
  • Checked where written: a state the machine does not declare is E1316, and a role no line the machine governs has is E1395, since a misspelled role would gate nothing.
  • The state read is the state the period closes in, as for every stream (§13.1): a line runs in the period its node's transition fires.
  • A model may add what runs in a state to a machine its pack declared, as it adds actions (§7.3.2): lists are extended, never replaced.

7.3.4 Master machines and their refinements

A master contract MAY declare a machine, once, in the language; Contract.Debt declares core.debt. A contract binds it as it binds any machine (§8.6), lifecycle core.debt, and a pack contract type binds it by naming it, its own refinements inheriting the binding. Binding is opt-in.

A pack refines a master machine under its own words, as it refines a master's roles:

[[lifecycles]]
lifecycle_id = "cre.construction_loan"
refines = "core.debt"

[[lifecycles.refined_states]]
name = "construction"
refines = "drawing"

[[lifecycles.transitions]]
from = "construction"
to = "interest_only"
guard = "months_between(contract.term_start, time.date) >= contract.draw_months"

[[lifecycles.runs]]
state = "interest_only"
lines = ["interest_reserve", "principal"]

A term a machine's guard reads, draw_months here, is a term the pack reads (docs/07 §6.4).

Rules:

  • A refinement is its master under its own words: the master's states, edges, run lists and arrival actions, with each renamed state renamed everywhere, state_enter reads included.
  • It may restate a guard, add an edge between states it has, add arrival actions and extend a run list ([[lifecycles.runs]]). It states no states and no initial, and removes nothing; a word for a state the master does not have, or two words for one, is refused at pack load.
  • A model augments a master machine as it augments a pack's (§7.3.2), with a block naming it: lifecycle core.debt { drawing -> interest_only when … } restates that edge's guard for every contract the model binds to it.
  • The master's word travels with the pack's. A status transition of a master machine or a refinement of one publishes the master's state as master beside to: debt in drawing is found whichever pack's word the model reads.

7.3.5 A state that includes a nested machine

A state MAY include a machine, which runs while the subject is in that state:

lifecycle loan {
  initial performing
  state performing includes core.debt
  state defaulted
  in performing run proceeds, interest, principal
  in defaulted  run nothing
  performing -> defaulted  when time.t == contract.default_period
  defaulted  -> performing when time.t - state_enter(entity, defaulted) >= contract.cure_months
}

Rules:

  • The status is the innermost state — interest_only, not performing — and the states around it are implied. Everywhere a state is named, an enclosing state stands for every state inside it: active in state performing, in performing run …, an edge out of performing, and state_enter(entity, performing), which dates entry into performing from outside it.
  • An edge out of an enclosing state leaves from any state inside it, and wins: where it and an edge of the nested machine both hold in a period, the outer one is taken — the condition decides over the phase. At most one transition per subject per period, as ever.
  • First entry opens the nested machine at its first state. Re-entry RESUMES it where it was (history): a cured loan resumes the phase it defaulted in. The resume is a continuation, not a fresh entry — the resumed state's entry actions do not run again, and its state_enter keeps its original entry, so a period defined on the calendar keeps counting through the default. The transition names the enclosing state it resumed as resumed.
  • A line runs only where every level allows it (§7.3.3): a level that lists the role allows the states it lists it in, a nested level also allowing every state outside it, where it is not running.
  • A state name is unique across the levels (E1396), and machines nest to any depth. An included machine that nothing declares is E1349; one that contains itself, directly or through another, is E1397.
  • An event or a contract's on start writing status to an enclosing state lands where history says, as the machine's own edge does.

8. Contracts (Behavior container)

For when to use contracts vs standalone streams, see the Language guide ("When to use streams vs contracts").

8.1 Contract declaration (canonical form)

Syntax (normative):

contract cre.lease on entity asset.sunset {
  term 2026-02..2028-01
  terms {
    rent = 42000
    escalation = 0.03
  }
}

Rules:

  • contract <TypeId> [<instance>] on entity <EntityRef> { term <Date>..<Date> terms { ... } }
  • <TypeId> is the pack contract type (e.g. cre.lease). An instance name makes an independent instance, and it is its own token: contract cre.lease_unit tenant_a. The fused spelling cre.lease_unit.tenant_a is the same declaration; both produce the qualified name cre.lease_unit.tenant_a, which is what lowered streams and references use. The type is checked where it is written: a type the pack does not declare is E1373, a master is E1374.
  • on entity states the contract's subject, the entity the agreement is written on. A contract that omits it is written on the model's only asset entity when there is exactly one, under warning W1393; with no asset entity, or two or more, there is no subject to choose and the model is refused with E1303.
  • term is REQUIRED, and it is the WINDOW part of the schedule language (§11) and nothing else: term 2026-02..2028-01, its long form term from 2026-02 to 2028-01, or a state entry, term from state_enter(party.acme, renewed) for 60 periods (§11.4). An anchored term opens where the walk finds the entity entering the state, and every stream the contract lowers runs in that window — which is how an exercise brings a contract into being: the option's action writes the state, and the agreement the right creates begins there. A re-entered state re-anchors. A field the contract lowers runs in the same window: it holds 0 until it sees the entry, which as settled state it does from the period after (§11.5), then steps through the window and holds. A term states no interval: how often the agreement pays is the pack rule's, and term every ... is refused where it is written. An anchored term names a state that marks the agreement's own beginning, not one the entity may already be in when the run opens, since the initial state counts as an entry. A contract that publishes a valuation states its term as dates (E5045).
  • The block is REQUIRED, and everything the contract states other than its name and subject is an item inside it, term included: a clause written before the { is refused (E0004).
  • terms is OPTIONAL; entries use <name> = <literal-or-expression>, separated by whitespace or a comma. A name may be a reserved word, as any name may (§18). A term stated twice, or a role bound twice in parties, is E1009.
  • payment is OPTIONAL and says where the lines the contract lowers fall in their period: payment net <n> [days|months], a lag after the period that earned the flow, or payment start|mid|end, a placement. A line may be named first by its role — payment rent start for rent payable in advance, payment revenue net 30 for an invoice paid a month later — and a named clause governs its line alone, the bare one every other line. Left out, a line keeps its rule's own placement. A line takes a placement or a lag, not both (E2109), and a line the contract does not lower is E1398. A contract's own effects stream places itself in its schedule.
  • Monetary amounts default to the model currency; streams declare currency explicitly.
  • Effects come from the active pack's lowering rules, which expand the contract into streams in the IR. A parties { <role> = <party> } block binds parties to the roles the contract's TYPE declares, checked against the effective roles of its master chain. An effects block holds the streams the agreement produces (§8.3). tags is reserved (§18.1), and a tags block is refused.
  • A contract's type is the master chain its pack type refines: its terms are checked against that chain's effective fields — an unknown term is E1371, a missing required term or an empty group of alternatives is E1372 — and a master itself cannot be declared: a model reaches a type through a pack's concrete refinement. The IR records the resolved type, its master, the instance and the parties with their master roles.

8.2 Terms block

  • terms { ... } is a set of named values.
  • Term keys MAY be qualified names (e.g., lease_up.months).

8.2.1 A term records what was agreed (normative)

A term's value is one of:

  • a literal — a number, string, date, or true/false;
  • a reference to one declared input, written inputs.<name> or, for a set's field, inputs.<set>.<field> (§12.7) — deferred to the run where the assumption is bodiless (§12.1) or overridden;
  • a reference to a declared contract or account, by name, where the type's field is of type contract or account (a guarantee's covered, a note's principal_account) — a name nothing declares is refused (E1376); or
  • an expression.

A contract records what was agreed — and what was agreed is often itself an expression. A lease escalating at "CPI plus 50 basis points" agreed exactly that; so did a coupon of "SOFR + 225bp". The term states it directly:

assume cpi = curve step {
  2026-01: 0.021
  2027-01: 0.024
}

contract cre.lease on entity asset.sunset {
  term 2026-02..2036-01
  terms {
    rent  = 42000                                  // literal fact
    escalation = inputs.cpi + 0.005  // the agreed formula
  }
}

An expression term is compiled at the term's own site (E5025_TERM_EXPR_INVALID if it does not parse) and substituted into the pack's lowering rule parenthesised, so a + b multiplied by another term associates the way it reads. It is valid only where the rule uses the term in an expression position — an amount or a field rule. A term a rule reads as a name, a date, a frequency, or a period count must stay a literal (E5026_TERM_EXPR_IN_LITERAL_SLOT, E5017_PERIOD_TERM_NOT_LITERAL).

A quantity that varies per run — a yield under study, an escalator being stressed — is still named as an input rather than computed inline:

assume annual_yield ~ Normal(mean=5000, stdev=350, clip=[4000, 6000])

contract energy.ppa.plant_a on entity asset.plant {
  term 2026-01..2050-12
  terms {
    price = 3000              // contractual fact
    quantity  = inputs.annual_yield  // driver, supplied per run
  }
}

The value then arrives from whichever layer is driving the run — assume x = … for a fixed case, a scenario's parameters in run.json, or a Monte Carlo draw. All three write to the same inputs.<name> channel, so one declaration serves every mode and variation stays layered on top of the contract rather than embedded inside it. An expression term may reference inputs (inputs.cpi + 0.005) and keeps that property.

Bounds are checked where the value is knowable. A literal term is checked against the pack's declared bounds at compile time. A term referencing an input is checked against that input's clip (E5011_TERM_CLIP_OUT_OF_BOUNDS), and referencing an undeclared input is an error (E5010_TERM_UNKNOWN_INPUT). A term deferred to inputs. carries the pack's bound into the IR, and the engine checks the value the run supplies — an override, a scenario value, a draw — at run start (E5041_INPUT_OUT_OF_BOUNDS), under the pack validation's own code. An expression term's value is computed per period and is not checked against the pack's bound.

Pack interaction:

  • A pack MAY provide a schema for <TypeId> and validate terms.
  • If no schema exists, terms are allowed but only minimally validated.

8.3 Effects block

  • effects { ... } contains the streams the agreement produces. Each is an ordinary stream declaration (§9.1), written inside the contract:
contract ground.lease.site_a on entity asset.site {
  term 2026-01..2035-12
  terms { rent_year = 120000  escalation = 0.02 }
  effects {
    stream ground.lease.rent inflow currency USD {
      schedule every month from 2026-01 to 2035-12
      category operating.revenue.rent
      amount = contract.rent_year / 12
               * pow(1 + contract.escalation, round_down(time.t / 12, 0))
    }
  }
}

Rules:

  • A stream in effects follows every rule of §9. Two things follow from where it is written: on entity MAY be omitted, and the stream is then the contract's subject's; and its amount and active when MAY read the agreement's terms as contract.<term>, spliced the way an option reads its agreement's (§14). A read of a term the contract does not state is E1372 — a read with no value is a missing term, never a zero.
  • The stream is emitted in the contract's effects.streams[] and in the model's top-level streams[] (docs/04 §6.7), attributed to the contract in the IR (provenance.contract) and in the results (contract on the series, the contract's streams in graph.contracts).
  • An effects stream MAY state which of the agreement's lines it is with line <role> — line proceeds, line interest. A pack rule stamps the role on every line it lowers; this is the same record for a line the model writes, published as the series' line, and it is what a machine names in in <state> run … (§7.3.3).
  • An empty effects {} produces nothing and is E2002, as a missing block is. Under a pack that lowers the contract, effects is optional and ADDS lines beside the pack's, and a term those lines read is admitted on the agreement though the pack type does not declare it (E1371 refuses only a term nothing reads): the pack is a shortcut, never a ceiling.
  • A model with no pack active writes every agreement this way; the contract's type is core.Contract.

8.4 Contract names and references

  • Contract instance names MUST be unique across the model.
  • Contract instance names MUST be qualified names with at least two segments; uniqueness applies to the full name.
  • Expression-level contract references (contract("...")) are reserved and not in the v0.1 dialect.

8.5 Term boundaries: what the agreement does to its subject

A contract MAY state what happens to its subject when its term starts and when it ends:

contract cre.lease_unit.r1 on entity asset.r1 {
  term 2027-03..2027-12
  terms { rent_year = 12000 }
  on start {
    set status = "leased"
    set in_place_rent = contract.rent_year
  }
  on end { set status = "vacant" }
}

The dates are the lease's and are stated once. Moving the lease moves the unit's state with it; an event on a date would restate them.

Rules:

  • on start runs in the first period of the term inside the run. A term that began before the model starts runs it in the first period, so a lease in place at the start places its unit. A term opening at a state entry (term from state_enter(…), §8.1) runs it in the period of each entry.
  • on end runs in the first period after the term: a lease through December leaves its unit in January. A term ending past the horizon never runs it, and a term that ended before the model starts runs neither.
  • The actions write the contract's SUBJECT. Field names are subject-relative, as an arrival action's are (§7.3.1), and the vocabulary is set only. A field the subject does not declare is refused (E1359). A value may read the agreement's terms as contract.<term>, spliced as an option's are (§14); a term the contract does not state is E1372.
  • set status is admitted, and checked. A term boundary is an occurrence, so its status write is the event's (§13.1): a state the subject's machine does not declare is E1316, a state no edge enters is E1353, and at run a move the machine does not declare from where the subject stands is refused, journaled declined and warned. A move that is taken runs the target state's arrival actions.
  • Ends run before starts. In each period every contract's on end runs, then every on start, in declaration order, and all of them before the period's events. A lease ending and the next beginning in the same period leave the unit in the second lease's state, each move checked as it is made.
  • The journal records each write with the contract as its actor, contract:<name>, and a transition names contract:<name> on start or on end as its cause.

A term boundary acts on the subject; it is not the subject's state. The condition a state stands for is the entity's machine's, and the agreement does not declare it. What a boundary does is an occurrence, the kind an option's exercise already is when it ends a loan or extends a lease (§14). Whether a right is exercised early, a lease terminated before its stated end, is still an event or an option: the term's stated end then finds the subject already moved, and its write is declined.

8.6 A contract's own lifecycle

A contract is an entity, and it MAY bind a machine of its own: the agreement's phases, or the agreement's condition, where the condition is the obligation's and not the asset's. A loan's delinquent, defaulted and repurchased are states of the loan agreement: the borrower missed a payment under it, and a buyout removes it from the pool.

lifecycle loan_phase {
  initial drawing
  state drawing, interest_only, amortizing
  drawing       -> interest_only when time.t - state_enter(entity, drawing) >= contract.draw_months
  interest_only -> amortizing    when time.t - state_enter(entity, interest_only) >= contract.interest_only_months
}

contract core.loan.senior on entity asset.site {
  term 2027-01..2028-12
  lifecycle loan_phase
  terms { commitment = 1000000  draw_months = 6  interest_only_months = 4 }
  effects {
    stream core.loan.fee inflow currency USD {
      schedule on 2027-01
      amount = contract.commitment * 0.01
    }
  }
}

Rules:

  • The machine's subject is the contract. Inside it the subject is written entity, as in any machine (§7.3), so state_enter(entity, drawing) dates the agreement's own entry into a state.
  • Its guards read the agreement's terms as contract.<term>, spliced per contract as the contract's effects and its options read them (§8.3, §14). A term the contract does not state is E1372. One machine may govern several contracts; each reads its own terms.
  • contract.term_start and contract.term_end read the term's dates, so a machine can open at the term's start and close after its end (time.date >= contract.term_start). A term that opens at a state entry states no date, and a machine reading one is E1372.
  • The agreement carries no fields. Its numbers are its terms, and its machine reads them rather than writing them, so an arrival action on a machine a contract binds is refused (E1359). What should change on an entity changes through the contract's on start or on end (§8.5), or an event.
  • A name no lifecycle block declares is E1349, as for an entity.
  • The contract is walked as the node contract.<name>. Its transitions are journaled and published under that name, and it appears in the results graph once, as a contract. An event's or an option's status write names that node, set entity contract.credit.loan.p2.status = "repurchased", and is validated against the contract's machine as an entity's write is against its own. A node no contract declares is E1301. Its state reads the same way, contract.<name>.status, in a guard, an event, a stream or a waterfall step: a covenant test acts only on a loan in performing or cured, and a trap step pays only while it is in breach or cured.
  • What an arrival would write is a line that runs in the state. The agreement carries no fields, so the end of a claim on arrival is not an action: it is a line listed only in the arriving state (§7.3.3), such as a write-off of the opening balance that runs in repurchased.
  • A contract's machine governs the agreement; its subject's machine governs the subject. A lease's own phases and its unit's condition are two machines, joined only by the writes the lease's term boundaries make.

9. Streams (cash flow vectors)

For when to use standalone streams vs contracts, see the Language guide ("When to use streams vs contracts").

9.1 Stream declaration (standalone)

Syntax:

stream asset.taxes on entity asset.sunset outflow currency USD {
  schedule every month from 2026-01 to 2031-12
  amount = 150000 / 12
}

Rules:

  • Streams MUST be owned by exactly one entity.
  • Streams MUST declare direction: inflow or outflow for cash, or accrual or writeoff for a movement of a balance with no money moving. An accrual raises a claim (capitalized construction interest, a PIK coupon, an interest shortfall); a write-off extinguishes one (a realized loss, a bond write-down). Both publish as series, are excluded from every cash total, category fold and valuation, carry no category (E1379), and MUST name the account they move (E1378).
  • A stream MAY say moves <account>: the account its amount moves — a claim declared on its owning entity by bare name, or any declared account by qualified name (E1380). Most streams move nothing. Whether a cash stream raises or lowers the balance follows from the account's side (§10.6), never from a word on the stream.
  • A stream MAY read an account's OPENING balance as prev.<account> — the prior close, or the init in the first period. It never reads a same-period close (E1382).
  • Streams MUST declare a currency, or take the one the model states (model "..." currency <code>, §5.6). A stream with no direction is E2114; one with no currency in a model that states none is E2115.
  • Stream names MUST be qualified names with at least two segments (e.g., cre.lease.base_rent, real_estate.ops_expense).

9.2 Stream declaration inside contract effects

A stream declared inside a contract's effects block is a stream of §9.1, with the two relaxations §8.3 states: the entity defaults to the contract's subject, and contract.<term> reads the agreement's terms. Pack lowering rules remain the other way a contract produces streams, and the two compose: a pack contract's effects block adds lines the pack does not lower.

9.3 Activation guards

Streams MAY include an activation predicate:

active when entity.status != "refinanced"

If omitted, streams are active for all scheduled occurrences.

9.4 Amount

  • Streams MUST define an amount expression.
  • Amount expressions MUST evaluate to Money or Decimal depending on slot; in v0.1, amount MUST evaluate to Money (or a Decimal that is implicitly converted to Money using the stream currency).

9.5 A stream has no window; a sale pays a valuation

A stream's amount MUST NOT fold a series over a window reaching past the period it pays in: a bound of the form time.t + k is refused (E2110). The income after a date is a valuation's to fold — a metric (§15.3) or a pack valuation (docs/07, valuations) — and the one causal amount that depends on it is a sale paying the valuation, written as the figure's name:

stream cre.exit on entity asset.project inflow currency USD {
  schedule on 2037-06 end
  category investing.disposal.reversion
  amount = metric.cre.exit.gross_value
}
  • A stream paying metric.<name> is DEFERRED: a sale paying its valuation, a loan sized on the income after its date. The walk runs twice. The first pass leaves the deferred streams out, and the figures fold over what it settles — tail included. The second runs every stream in place, the deferred ones paying those figures, so a waterfall distributing at the sale period allocates proceeds that exist, and logic, accounts and later streams read the deferred amount as any other. The metric MUST exist (E2111), and it MUST NOT fold the stream that pays it, by name or through a subtotal the stream's category is classified into (E2112).
  • A subtotal is read once its cash has settled. A stream, a field, a guard, an event or an option may fold a money subtotal — the model's domain.*, or one entity's entity.<symbol>.<subtotal>, folded over its streams and its parts' — over a window ending before the period it is computed in: a management fee on last month's effective gross income, recoveries on the twelve months of expenses before. A STREAM may also read the period itself, a window ending at time.t: a construction loan meeting the period's interest from the period's operating income. The engine computes it after every stream the subtotal folds, so the stream must not be one of them, directly (E2113, at compile) or through what it reads (E5035, at run, naming the cycle). A field, a guard, an event and an option settle before any stream of the period, so they read strictly backward. A read reaching a cell not yet folded is refused: at compile where the window plainly reaches past what it may (E2113), at run otherwise, never read as zero. A ratio subtotal is not causal.
  • The graph must stay acyclic, and only a real cycle is refused. Logic may read a deferred stream, or the opening balance of an account it feeds: a loan's interest reads the balance its sized proceeds opened, and a covenant test reads both. What may not happen is a figure folding what depends on it — sale proceeds feeding state that feeds the income being capitalized. The figures are folded again over the second pass; one that moved depends on its reader, and the run is refused with the stream that moved and the logic that read the deferred amount named (E5035).
  • A literal period bound (series_sum("x", 24, 24)) compiles; the walk refuses the read at run if it reaches past what has settled. A forward window in a GUARD is refused at compile: whether a stream is active is a causal fact, and a fact cannot be read from the future.

10. Waterfalls (ordered allocation)

Some cash is not earned, it is allocated. A waterfall declares a priority of payments: an ordered list of steps sharing out a pot.

10.1 Waterfall declaration

Syntax:

waterfall deal.distribution on entity asset.trust {
  schedule every month from 2026-01 to 2030-12
  from available

  pay servicing to party.servicer    = 12500.0
  pay senior    to asset.class_a     = 6250.0
  pay residual  to party.certificate = remaining
}

Rules:

  • A waterfall MUST be owned by exactly one entity.
  • A waterfall MUST declare a schedule — the same construct a stream takes — and a from expression, which is the pot. Write from available for this period's netted stream cash and from <account> for what has accumulated; an expression remains legal for a pot neither can say, and it is the model's own claim, checked only for the series it names. The schedule's placement is every step's: a waterfall that distributes on day 25 pays each step on the 25th, and the step's published series carries that offset.
  • Waterfall names MUST be qualified names with at least two segments.
  • A waterfall MUST declare at least one step.

10.2 Steps (normative)

A step is pay <name> to <payee> = <expr>. There is one form for every kind of payment; a fixed fee, a cap, a balance target and a shortfall are all arithmetic over the names in §10.3.

  • Step names MUST be unique within their waterfall.
  • Every step MUST declare an amount expression.
  • Steps are paid in declaration order.
  • A step pays min(max(0, expr), remaining). A step asking for more than is left takes what is left; a negative expression pays nothing rather than clawing cash back.
  • At least one step MUST read remaining, so the residual has a named payee instead of vanishing, unless the waterfall draws from <account>: what its steps leave stays in the account for the next scheduled date (§10.6), so nothing vanishes (E1344).

A step may say which agreement and line it pays. pay <step> to <payee> for contract <name> line <role> = <expr> names a declared contract and one of the lines its type declares ALLOCATED — a structured note's principal, an equity interest's distribution. The step's series then carries the contract and the line, the results graph lists the step under the contract, and a slice or statement row by type and line reaches it. A contract nothing declares is refused (E1376); a line the type does not declare allocated is refused (E1377), because a line a rule lowers is paid by the rule and a step paying it would count the cash twice. A contract written on an entity outside the waterfall's subject and its part of family is warned (W1390): a waterfall distributes its own subject's cash.

A step paying the holder pays the holder's line without naming it. Each allocated line names the role it is paid to: a security's interest and principal and an equity interest's distribution go to the holder, a guarantee's claim to the beneficiary. A step written without for contract … line … that pays a party, or an account a party owns, pays the allocated line of the agreement binding that party in that role, when exactly one agreement on the waterfall's subject or its part of family, and one such line, qualify. The step then carries the contract and the line as if it had named them. Otherwise it names them: a party holding two such interests, or a note, whose interest and principal both go to the holder. A step left unattributed pays the party all the same, and W1389 reports the line no step pays.

A contract may contribute its step. pay for contract <name|"selector"> line <role> is one step per contract the name or selector matches (a quoted selector is exact, or ends in one .*), each from the [[steps]] rule the contract's pack declares for that allocated line (docs/07 §6.4): its name, its amount, and the account it pays, or, where the contract states none, the party bound in the role the line is paid to. Each step names its contract and line, as a written one would, and is paid in its place among the waterfall's other steps. The contributed steps are paid by seniority, the security's rank, 1 first: contracts that state one come first, ascending, then the rest, in declaration order.

waterfall notes.principal on entity container.trust {
  schedule every month from 2018-10 to 2024-01
  from principal_collections
  pay for contract "credit.note.*" line principal
}

A name or selector that matches no declared contract, or matches only contracts whose pack contributes no step for the line, is refused (E1392). A step is written by hand where the deal departs from the pack's rule: a pro rata class, a capped payment, a target balance.

10.3 What a step may read

On top of the ordinary expression environment (§16):

bindingmeaning
remainingwhat is still in the pot at this step
paid.<step>what an earlier step actually paid
owed.<step>what an earlier step would have paid, unbounded

owed and paid differ exactly when a step could not be paid in full, so their difference is that step's shortfall, which every step also publishes (§10.5). A step's amount is therefore what the payee is OWED at that clause: interest due in full on the date is written as the interest due, and a short pot shows as a shortfall; principal owed only to the extent there is principal to pay is written min(remaining, <claim>), and is never short.

A step MUST NOT read a step declared after it. A priority of payments is an order, not a system of equations, so a forward reference is a compile error.

10.4 Evaluation order (normative)

A waterfall runs after the period's fields and streams are evaluated, so it allocates cash that already exists. A waterfall MUST NOT feed a stream in the same period.

The schedule stays sovereign: a waterfall distributes only at the periods its schedule names. On every other period its cash accumulates — with the entity, or in a declared account (§10.6) — and a quarterly or at-exit waterfall distributes the accumulated position when its date arrives.

10.5 Output and composition

Each step publishes as a series named stream.<waterfall>.<step>, and its cash counts toward the payee's total. Like every published series it covers the cash horizon: a projection tail (time … project <n>) is computed so a later waterfall or a metric may read a step past the horizon, and is never published. A waterfall is not a separate kind of output: statements, metrics and the results document read it as they read any stream.

Each step also publishes its shortfall, as shortfall.<waterfall>.<step>: what the step was owed less what it took, per period, zero on a period the step was paid in full or the waterfall did not run. A shortfall is the absence of cash, so it is a bare number, never cash: it enters no total, no net cash flow, no category fold and no valuation, as an account's balance does not (§10.6). It carries the step's entity, and the contract and line where the step names them, so a note's interest shortfall is found under the note. A metric folds it by that name.

A step's series carries the entity the step pays, and so does its shortfall. The payee is the party the step names; for a step paid into an account, the party that owns the account, or the account's own name where the structure owns it. The entity that hosts the waterfall has earned the cash it distributes, so its entity.<symbol>.net_cash_flow is that cash before the split, each payee's is what it receives, and the series attributed to an entity are the parts of its figure, apart from its non-cash streams, which the figure leaves out, and an option's payoff series, which carries no entity although the figure counts it. The payee is read from the step by its full name, <waterfall>.<step>, so a waterfall whose name has two segments (notes.interest) attributes its steps as one whose name has one. A slice reads a step differently, as what the subject pays out (§15.4).

Because steps publish as series, a waterfall MAY draw on the output of a waterfall declared before it — a fund's carry becoming a management company's pot, and that company's own share becoming a third waterfall's. Composition follows declaration order, the same rule steps follow within a waterfall. A step of a later waterfall reads an earlier one's step or shortfall the same way, by a series reduction at time.t. A read of a step or a shortfall from the same waterfall or a later one is refused (E1342), and from a stream, a field, an event, an option or an account's inflow (E1346).

10.6 The account

Carried cash gets the industry's own object: a declared cash location whose balance accumulates across periods — a collection account, a reserve, a participant's distribution account.

account reserve {
  owner party.sponsor
  from series_sum("ops.net", time.t, time.t)
}
  • owner is optional: a general account belongs to the structure, and a party-owned account holds what has been allocated to that party. A party MAY own several — a noteholder's principal and its interest, a partner's capital and its preferred return — each a position the waterfall pays separately. pay <step> to <party> means the party's one account and is refused (E1324) when the party owns more; the step then names the account, to account <name>. A party's return (irr, moic) folds every account it owns. A party-owned balance is not an obligation — what is still owed under the rules is not in any account, it is simply not yet allocated.
  • An entity may own an account: a claim declared in its block, account balance owed init 1000000, named <entity>.<account> — asset.loan.balance. Its side, owed (a liability of the owner) or due (a receivable), decides which way a cash stream that moves it changes it; a structure account may declare a side the same way, after its name. A pack contract's account takes its side from the master, so no modeler using a pack writes it.
  • A pack contract opens its account per instance: <subject>.<name>.<instance>, where the instance is the contract's id, senior in cre.permanent_debt.senior, or its type word, permanent_debt, when it has none. Two loans on one tower each carry their own balance, and adding a contract never renames another's account. The subject's <subject>.<name> is the fold of its instances, read as prev.asset.tower.balance and moved only by moving an instance (E1384), as a container's fold is.
  • init <expr> is the balance at the timeline's first period. It defaults to zero: a balance outstanding when the model opens states it; one created during the run is raised from zero by the cash that creates it, and prev.<account> is never absent.
  • Streams move it (§9.1): moves <account> on an inflow or outflow, an accrual or a write-off. Each movement is a journal line naming the stream that caused it.
  • The relation folds it. A container's account of a given name is the sum, opening and closing, of its members' accounts of that name through part of — container.trust.balance when the trust's loans each carry a balance. It is declared nowhere (E1383), moved only by moving a member's (E1384), read as prev.container.trust.balance and published as account.container.trust.balance. Its members are the leaves: each instance account beneath the trust, counted once. Cash and balances roll up through the same relation by the same rule. A FIELD does not: a balance is a stock of money and always adds, while a field may be an area, which adds, or an occupancy, a coupon or a date, which do not. A model sums a field over the parts with part_sum (docs/03 §4).
  • from is the per-period inflow, reading cash that has settled this period. It MAY be negative, and the balance has no floor: an account fed a deal's whole net cash IS the deal's cumulative position, negative through the J-curve and positive after.
  • There is no currency clause: an account is denominated by the model.

The balance law, applied at each period:

balance(t) = balance(t-1) + inflow(t) + moved(t) + allocated_in(t) - allocated_out(t)
balance(-1) = init

where moved(t) is what the streams naming this account moved: on an owed account an inflow raises and an outflow lowers, on a due account the reverse; an accrual raises and a write-off lowers on either side.

Three uses:

  • A waterfall draws from an account — from <account> in place of a hand-written cumulative window. The pot is the accumulated balance, floored at zero (cash that is not there cannot be allocated); what its steps take leaves the balance, and residue stays for the next scheduled date.
  • A step pays to an account — pay <step> to account <name> = <expr>, the reserve pattern's mechanism (fund to target, top up when short, release when over). The account may be one the model declares or one a contract opens, <subject>.<name>.<instance>: an energy reserve's top-up pays its own reserve account. pay <step> to <party> lands in that party's account when they own one, and behaves exactly as before when they do not.
  • Logic, a step and a stream read a balance — prev.<account> is settled state, strictly backward: the balance at the previous period, every allocation and movement through it included. In the first period it is the init. A stream reads it too — interest on the opening balance is the ordinary case — and nothing reads a same-period close.

A step's series is the FLOW and the account's balance is the POSITION. The balance publishes as a non-cash series under account.<name>, never enters cash totals, and every movement — inflow, allocation in, allocation out — is journaled with the balance before and after.

available is unchanged and still means this period's netted cash; an account is the ACCUMULATED cash. The two answer different questions, and a monthly-distributing waterfall keeps using available untouched.


10.7 Writing a priority of payments

A waterfall is the priority of payments the governing document writes, in the document's order. The default is ONE waterfall per amount the document defines. Most asset-backed indentures define one amount, available funds, and one ordered list in which interest, principal, reserve deposits and releases all appear; that is one waterfall. Four mechanics recur, and none is a construct of its own:

  • A trigger that diverts cash is a branch inside the list: a step whose amount is an if on the test. A collateralization test that, when failed, pays a senior class's principal out of interest is a step in the interest list paying that class's principal account, owed the amount that restores the test and zero when the test passes.
  • A deficiency cured from later periods is a step whose amount is a gap between two balances, not a flow: notes outstanding less the collateral, read as prev. balances and claims. What one period cannot cure is still in the gap at the next, so the cure carries forward with no ledger of its own. A document that publishes the ledger's balance is a field over the same two balances.
  • Unpaid amounts that stay owed are a claim that reads what the payee's account has received: interest owed to date less prev.<account>. The step's shortfall is what was not paid this period; the claim carries it.
  • Cash restricted by source is the one case for two waterfalls. Where the document defines two amounts, each with its own list, because it restricts which cash may pay which clause (a CLO's interest proceeds and principal proceeds, a European RMBS's revenue and principal priorities), write two waterfalls, each drawing from its own account, declared in the order the document applies them. A clause of the earlier list that pays into the later one's source is a step paying to account that source; a fall-back clause of the later list, such as "to the extent not paid under clause (C)", is a step reading the earlier step's shortfall:
waterfall notes.principal on entity container.trust {
  schedule every month from 2026-01 to 2026-12
  from principal_proceeds
  pay a_interest_from_principal to account a_interest
        for contract credit.note.a line interest =
        series_sum("shortfall.notes.interest.a_interest", time.t, time.t)
  pay for contract "credit.note.*" line principal
  pay excess_principal to party.equity = remaining
}

A single list must not be split into two waterfalls, and two lists must not be merged into one: a merged list would pay a clause from cash the document withholds from it.

11. Schedules (important)

Schedules define occurrence dates/times for streams.

11.1 Core schedule grammar

Schedules appear inside a stream block:

schedule <schedule_expr>

11.2 Schedule expressions

11.2.1 One-time

schedule on 2026-02-15

11.2.2 Bounded recurring (frequency)

schedule every month from 2026-02-01 to 2028-01-31

11.2.3 Position in the period

schedule every year start from 2026-01 to 2030-01 // start of each period
schedule every year mid   from 2026-01 to 2030-01 // halfway through
schedule every year end   from 2026-01 to 2030-01 // end (the default)

schedule on 2029-06 start                         // one-shot (the default)
schedule on 2029-06 mid                           // one-shot, same axis
schedule on 2029-06 end                           // one-shot, settles at close

One axis, three positions, at most one. start, mid and end say where in its period a payment sits, and so how far it is discounted. Stating two is a parse error rather than a diagnostic — the positions are alternatives, not flags.

What differs between the forms is only which position they DEFAULT to when the model says nothing: a recurrence defaults to end (an ordinary annuity — the interval elapses, then payment falls), a one-shot to start (it settles on the date stated, not after waiting a period it never waited through). Because the default is not a single constant, every position is nameable in both forms so a model never has to rely on it.

start is an annuity due, which is what expense-like streams want. mid is the project-finance mid-period convention: cash arrives through the period rather than at one end, so it is summarized at the midpoint — half a period on every calendar, unlike a day rule. Stating two positions at once is E2109_SCHEDULE_CONFLICTING_PLACEMENT.

11.2.4 Day rules

schedule every month on day 1 from 2026-02-01 to 2028-01-31
schedule every month on eom from 2026-02-01 to 2028-01-31

11.2.5 Business-day conventions

schedule every month on eom
  from 2026-02-01 to 2028-01-31
  convention modified_following
  calendar "NYSE"

Conventions:

  • none
  • following
  • modified_following
  • preceding
  • modified_preceding

11.2.6 Partial periods

A schedule has no stub policy. stub is a reserved word (§18.1), and a schedule that writes one is refused (E0004); a partial first or last period is its own schedule.

11.2.7 Include/exclude

schedule every month on eom from 2026-02-01 to 2028-01-31
  except [2026-12-31]
  also [2027-01-02]

11.2.8 Phase-relative helpers

schedule on phase_enter("perm")
schedule every quarter from phase_start("hold") to phase_end("hold")

phase_enter is a one-shot on the phase's first date and takes the options of §11.2.5 and §11.2.7. phase_start and phase_end name the same phase, and the recurrence runs over its dates; it takes a placement, payment terms, a day rule and the same options as any recurrence. A phase_end that names another phase is refused (E0004).

11.3 Schedule resolution rules (normative)

  • A schedule MUST resolve to a set of dates.
  • If the master timeline is not daily, dates MUST be mapped deterministically to the nearest representable period boundary according to the schedule’s convention.
  • The compiler MUST either:
    • (a) define a deterministic mapping rule, or
    • (b) reject schedules that cannot be represented at the timeline grain.

A schedule MUST NOT be finer than the master timeline. An interval of week on a monthly calendar, or month on a quarterly one, is rejected (E2108_SCHEDULE_FINER_THAN_CALENDAR) rather than silently collapsed: several occurrences in one period cannot be distinguished once they land in the same bucket. Coarser is fine — a quarterly schedule on a monthly grid pays in every third period, which is representable.

The grain rule. E2108 is not a limitation to be worked around; it is the enforcement of the rule the language is built on:

Model at the finest grain at which anything varies; report at any coarser grain by folding.

No granularity is imposed. A model may be annual, monthly or daily, and the timeline it declares is the grain at which its expressions are evaluated — evaluate_stream builds its environment from timeline[idx], so every occurrence inside one period sees an identical environment. A constant amount is therefore exact; anything varying with time.* is computed once and multiplied.

That is precisely why a finer schedule is rejected. Take a monthly mortgage on an annual grid. The level payment is constant, so twelve identical evaluations sum to the right annual figure — but decomposed into interest and principal, interest is balance x rate/12 against a balance that falls every month, and all twelve evaluations see the same time.t. Interest is overstated and principal understated while still summing to a total that looks correct. Wrong at every line beneath the one line that looks right.

If interest varies monthly, the model is monthly. Reporting it annually is then a regrouping of the same ledger and costs nothing: a statement, a rollup and a valuation each name the grain they report at, and many coexist in one run. See docs/06_results_schema.md for StatementGrain.

An earlier proposal took the opposite trade — retire E2108 and add a sub-period occurrence layer so a model could compute finer than its grid. It was rejected.

Recommended v0.1 rule (normative):

  • If timeline is monthly/quarterly/annual, schedule occurrences are represented at that period’s end-date unless the schedule specifies otherwise.

11.4 State-anchored windows

The third anchor, beside dates and phases: a state entry.

schedule every month from state_enter(asset.site, building) for 18 periods

Each ENTRY of the entity into the state opens its own window of n grid periods, resolved during the walk — the entry period is settled state by the time any stream reads it. This is what "18 months of construction from whenever construction starts" needs: the machine enters the state whenever its edge fires, the schedule hangs off the entry, and the activity window carves itself out of the grid.

  • A re-entered state re-anchors: each entry starts its own window — a second delinquency's cure period, a renewal restarting its term. A self-edge is an entry.
  • A contract's term takes the same anchor (§8.1): term from state_enter(party.acme, renewed) for 60 periods opens every stream the contract lowers on the entry, with no interval of its own.
  • Within a window the schedule behaves exactly as every <interval> from <entry> to <window end>: intervals, placement, day rules, payment terms, conventions and include/exclude dates mean what they mean everywhere else, and are written where they are written there: schedule every month on day 15 from state_enter(asset.site, building) for 18 periods convention following.
  • The anchor's entity must have a machine and the state must be declared — the same finite-set discipline every other state reference has. state_enter and periods are contextual identifiers.
  • Truly linear time keeps its constructs: calendar-fixed eras are phases, condition-driven regimes are machine states, and this anchor belongs to the second.

11.5 The same entry, read in an expression

The words that anchor a schedule also read as a value:

amount = stabilized * clamp((time.t - state_enter(asset.site, lease_up)) / 18.0, 0, 1)
construction -> lease_up
  when time.t - state_enter(entity, construction) >= entity.construction_months

state_enter(<entity>, <state>) evaluates to the PERIOD at which the entity most recently entered the state, as an integer on the run's grid. One spelling, one idea: what anchors a window in §11.4 is the same moment an expression reads here, so elapsed-since-entry is a subtraction rather than a counter field kept in step with arrival actions by hand.

  • Most recent, not first. A re-entered state restarts the clock, exactly as a state-anchored schedule re-anchors.
  • Both names are written bare and resolved by the compiler, the way a schedule anchor's are: an entity the model declares and a state its machine declares. An entity with no machine is E1349_UNRESOLVED_LIFECYCLE_REF; a state the machine does not declare is E1316_UNKNOWN_LIFECYCLE_STATE. A quoted state could only be checked by being compared, which is the failure active in state exists to prevent.
  • Inside a lifecycle guard or arrival action the subject is written entity — the same entity-relative root that reads entity.<field> (§7.3). One machine is bound by many entities, so a guard can never name an instance.
  • Not yet entered has no value and is refused, naming the entity and the state. Every substitute is worse: zero says the entity entered when the model opened, and a future period says it entered on a date nothing decided. Guard the read on the condition that puts the entity there — a stream's active in state does this by construction — or anchor on something that has already happened.
  • The reading never runs ahead of the walk. Only entries the state stage has settled are visible, the same discipline series_available_to applies to a series read, so the answer does not depend on the evaluation order.

12. Assumptions (deterministic and stochastic)

12.1 Deterministic assumption

assume base_rent = 4000
assume renewal : fraction = 0.85
assume cap_rate : rate = 0.065 within [0.04, 0.10]
  source { publisher "Midtown office cap rate survey"  as_of 2026-06-30 }
assume ti_allowance = 60 "USD/sf"

A deterministic assumption is a named value the model owns. Terms and expressions read it as inputs.<name>, and a scenario overrides it by the same name, which makes assume the model's single channel for variation.

Type. assume <name> : <type> = … declares the value's type — fraction, rate, decimal, int or duration (§5). The type gives the value its domain (§5.7): a fraction is 0 to 1, a duration is whole and non-negative. Untyped, an assumption is a Decimal. A type the language does not have is E2305_ASSUME_UNKNOWN_TYPE. On a set (§12.7) the type slot names a pack's set type instead.

The modeler's own bound. within [lo, hi] states the range this deal admits, on top of the type's domain, and is CHECKED — never clamped — wherever the value arrives: a literal here (E2307_ASSUME_OUT_OF_BOUNDS), an override, a scenario value or a draw at run start (E5041_INPUT_OUT_OF_BOUNDS). A bound that is inverted or reaches outside the type's domain is E2306_ASSUME_INVALID_WITHIN. within is a contextual word, an ordinary identifier elsewhere. A pack's bound on a term states what the term is; a model's within states what the deal is — the two are checked together.

Unit. A value admits the unit literal a term does, 60 "USD/sf" or 250000 MWh. It is an assertion, never a conversion: on a field of a set a pack has typed it is checked against the unit the type declares and refused where written if it differs (E2310_ASSUME_FIELD_INVALID), as a term is held to E5024; on an untyped assumption it is recorded, shown on the review page, and checked by nothing. Nothing requires a modeler to write one.

A string. A value may be a quoted string where the number is a classifier — a use, a grade, a jurisdiction. It is read as its text and has no place in arithmetic. A bare word is neither a number nor a string and is refused (E2310): downtime_months = nine would be a name read as nothing.

Source. Every assumption may say where its number came from (§12.8). One with no source is recorded as stated in the model.

Timeless. An assumption is evaluated once, at run start, before any period exists. Its expression may read other assumptions, an entity's literal fields, and a series or a quantile through its function at a stated date (curve_value(inputs.cpi, date(2026, 1, 1))). It may not read the reader's period: time.*, prev and a state, a series reduction, a metric, a subtotal, a field that carries a rule, or a series bare, which is the series at the period. Each is refused where written (E2313_ASSUME_READS_PERIOD). A quantity that moves with the period is a calculation: state it in a field, a stream or a metric, where the review page shows it as one.

Addressed by full name. A scenario's parameter, a run-configuration override and a Monte Carlo distribution name an assumption by its full name, inputs.retail_market.renewal.rent_share for a set's field (§12.7), and each replaces the value the model stated: a value is replaced, a distribution is fixed by a scenario and sampled by a trial, a set is reached field by field. The run's values land before any assumption is evaluated, so an assumption derived from an overridden one reads the override. A series or a quantile is replaced through the inputs file, below, not by a scalar parameter.

No body. assume sofr : cre.base_rate states a type and no value. That absence means the value is external and required: the run supplies it, or the run is refused before any period exists, naming the assumption (E5045_INPUT_NOT_SUPPLIED), never read as zero. The type is required (E2314_ASSUME_BOUND_UNTYPED), since it is all the model says about the number; a pack observable gives the value its unit. The shape is the one the supplied body declares: a value, a series or a quantile function. An assumption with a body is stated with a fallback the run may replace; one without is bound. Every bodiless assumption enters the IR's required_refs (§17.3), the list of what the model needs from outside, and so does one with a body whose type is a pack observable (§12.6), recorded with its fallback. An assumption with a body and no observable type is the model's own figure and is not listed.

assume sofr : cre.base_rate
  source { publisher "CME"  series "SOFR" }
assume cpi : rate = curve { 2026-01: 0.031 }
  source { publisher "U.S. Bureau of Labor Statistics"  series "CUUR0000SA0" }

The inputs file. A value arrives in one of three ways, each filling the same slot by full name: a run-configuration parameter, inputs.<name>, for a value; an inputs file, a JSON document the run configuration names under inputs in the deterministic case or a scenario, keyed by full assumption name, each entry carrying one shape body — value, series with its points and interpolation and effective dates, or quantile with its points — and its own source; or a data connector, which is anything that writes that file, and which the language never sees. A supplied value is checked as the declaration is: its type, its unit, its within, a series against its effective dates. The review page records what filled each row — stated in model, supplied by run, or the source the file carried — so two runs of one model against two snapshots differ in that row. A scenario names an inputs file of its own, so a stress case is a different snapshot and nothing else (the run schema in docs/09 §11). A scenario is a whole deterministic run and is published whole: its series, journal, transitions, slices, review page and statements beside its metrics (docs/06), so two runs can be compared cause by cause (docs/09 §9, comparing runs).

Discounting is not an assumption. The valuation rate belongs to the run, so one set of cash flows can be valued at several rates without editing the model. It is annual_discount_rate in the run configuration; see Language guide. An assume of that name is an ordinary assumption and does not move model.npv.

A rate that varies over time is a series-shaped assumption (§12.5) the run selects: annual_discount_curve names it, in place of the scalar rate and never beside it. The run reads the series at each period's date for that period's annual rate and discounts each period by the product of the rates walked before it — the cumulated discount factor a valuation table prints — with a stream's placement offset taken at its own period's rate. A flat series is the scalar rate exactly. model.irr is the single rate at which the present value is zero and is unaffected. The run publishes run.annual_discount_curve in place of run.annual_discount_rate.

12.2 Stochastic assumption (distribution)

assume rent_growth ~ Normal(mean=0.03, stdev=0.01, clip=[-0.02, 0.08])
assume occupancy : fraction ~ Normal(mean=0.92, stdev=0.03, clip=[0.7, 1.0]) within [0.5, 1.0]

A distribution takes the same : <type> and within as a constant. clip truncates the draws and is unchanged; within refuses a draw outside it, and a clip that can produce a value outside the type's domain or the within is refused at compile time (E2306), since the draws would break the bound.

Supported distributions (v0.1 core):

  • Normal(mean, stdev, clip?)
  • LogNormal(mu, sigma, clip?)
  • Uniform(min, max, clip?)
  • Triangular(min, mode, max, clip?)

Every family takes clip=[lo, hi]. On a bounded family a clip inside the support truncates the draws to a narrower range without restating the distribution: Uniform(min=0, max=1, clip=[0.2, 0.8]) draws a draw below 0.2 as 0.2.

12.2.1 Sampling: a draw per entity and period

A distribution is declared once and read two ways:

readgives
inputs.<name>one draw per Monte Carlo trial, the same everywhere in the trial
sample(inputs.<name>)a draw for the reader's subject entity and period

A deterministic run gives both reads the distribution's clipped central value, as it does for every distribution. A scenario that fixes the assumption fixes both reads.

assume u   ~ Uniform(min=0, max=1)
assume eps ~ Normal(mean=0, stdev=1)

lifecycle loan {
  initial current
  state current, defaulted
  current -> defaulted when sample(inputs.u) < inputs.monthly_default_prob
}

entity asset index {
  rate init 0.04 next prev + inputs.kappa * (inputs.theta - prev) + inputs.sigma * sample(inputs.eps)
}

Rules:

  • The argument is a distribution-shaped assumption, named as one. A value, a series, a quantile or a set is refused (E2312).
  • A draw is keyed by the assumption's full name, the trial, the reader's subject entity and the period. The subject is the entity whose field the rule computes, the stream's owner, or the entity a lifecycle guard or arrival action moves. The same key always gives the same value: a guard and the arrival action it leads to read one draw, and two reads in one rule agree. Two independent draws need two assumptions.
  • It is read where a subject and a period are both bound: a field rule, a stream (a contract's lowered stream included), a lifecycle guard and an arrival action. An event's when belongs to no entity, and an option's election, a waterfall, an account and a metric move no entity's state, so a sample there is refused (E2315). An assumption is evaluated before any period exists, so a sample in one is refused (E2313); inputs.<name> alone is what an assumption may read.
  • A shock many readers share is a field. A market rate path is one field on one entity, and the entities exposed to it read that field. A per-entity draw is independent of every other entity's by its key. No correlation construct is added (§1.1, §17.4).
  • A decision by chance is drawn on arrival and stored. The branch edges out of the state read the stored fact positively, so one draw decides every edge. A deterministic run reads a uniform's central value, 0.5, so the unit below renews when the probability is at least one half: the likelier branch, not a blend. The expectation over branches is a different model, a probability-weighted blend of the cash, and needs no state.
on enter rollover {
  set renews = if(sample(inputs.u) <= inputs.renewal_probability, 1, 0)
}
rollover -> leased when entity.renews == 1
rollover -> vacant when entity.renews == 0
  • The journal records each draw. A transition's note lists sample(inputs.<name>)=<value> beside the other values its guard read, and an arrival action's line does the same.

12.3 Where distributions may appear

  • Distributions MUST be declared via assume <name> ~ Dist(...), at the top level or as a field of a set (§12.7).
  • Any term or expression can reference stochastic values via inputs.<name>, and a field rule, a stream, a lifecycle guard or an arrival action can draw per entity and period with sample(inputs.<name>) (§12.2.1).

12.4 Reproducibility (normative)

  • Any Monte Carlo run MUST declare an explicit seed.
  • Engines MUST use this seed deterministically for sampling.
  • A draw is keyed, never sequenced. A trial's draw of an assumption is keyed by the seed, the trial and the assumption's full name; a sample is keyed by those and the reader's subject entity and period (§12.2.1). Adding an assumption, an entity, an edge or a read therefore moves no other draw.

12.5 Series (date-indexed)

A series is a shape of assume: a named, date-indexed value (a forward rate curve, a price deck, a funding schedule) that a model states once and reads by date.

assume sofr : rate = curve {
  2026-01: 0.048
  2026-07: 0.045
  2027-01: 0.042
} source { publisher "CME"  series "SOFR"  as_of 2026-01-02 }

assume power_price = curve linear {
  2026-01: 42.0
  2036-01: 55.0
}
  • Reading. inputs.<name> is the value at the reader's period date; curve_value(inputs.<name>, <date>) is the value at another date. The compiler rewrites the bare read to curve_value(inputs.<name>, time.date), so both spellings carry the same effective-date rules below. The first argument names the assumption and is never a string: curve_value("sofr", …) is refused (E2312_ASSUME_SERIES_READ), as is curve_value over an assumption that is not a series, or a series in an entity's = field, which holds one value.
  • Interpolation is step (default; flat-forward: the last point at or before the query date, the first value before the first point) or linear (linear in calendar days between bracketing points, clamped flat outside the range).
  • A series takes the clauses every assumption does (§12.1): a type, a unit, within, which is checked on every point (E2307), and source. It may be a field of a set (§12.7), read by full path.
  • A series MUST declare at least one point and at most one value per date (E5008_INVALID_CURVE); two of one name are E1005.
  • A series is not sampled (§12.4). Uncertainty about its level is an ordinary assumption scaling the read.

Effective dates. A series MAY state the dates it is good for on its header, from <date> and/or to <date>, the way a phase states its span:

assume macrs_5 = curve from 2026-01 to 2031-12 {
  2026-01: 0.20
  2027-01: 0.32
  2028-01: 0.192
  2029-01: 0.1152
  2030-01: 0.1152
  2031-01: 0.0576
}

assume sofr = curve to 2029-05 {
  2026-01: 0.048
  2028-01: 0.0385
}
  • Inside the effective dates the points and interpolation apply as above, including the flat hold between the last point and to.
  • A read outside them has no value: the run is refused (E5040_CURVE_READ_OUTSIDE_RANGE), naming the series, the date and the reader. A series' value is not its end value held forever; the header says where the claim stops.
  • Every point MUST lie inside the effective dates (E5008).
  • A series that states no to keeps the flat-forward convention past its last point — the market's reading of a rate deck quoted shorter than the deal — and a stream or field whose periods run past that point is warned once (W5024_CURVE_READ_PAST_END). Stating to answers the warning either way: the hold is meant, or the reader ends where the series does.

12.6 Quantiles (share-indexed)

A series (§12.5) is indexed by when. A quantile is indexed by how much — values against cumulative share, for a quantity whose dispersion drives the answer rather than its level. It is the third shape of assume:

assume ercot_north : energy.power_price = quantile linear by exceedance {
  1.00: 512.0
  0.98: 340.0
  0.50:  28.0
  0.00:  11.0
}
  • Shares MUST lie in 0..1. The physical measure — hours in the year, pool balance, rentable area — belongs to whatever reads the quantile, so one declaration serves assets of different size.
  • Values MUST be non-decreasing in share. A quantile function that fell would leave quantile_of without a single answer.
  • Interpolation is step (the last point at or below the query share) or linear, with the same meanings they carry on a series. It also fixes quadrature: quantile_mean is the exact integral of the function these describe, so no separate quadrature mode is declared and none is needed.
  • by exceedance writes the points worst-first, as a duration curve reads. It is authoring surface only: the compiler normalizes to one ascending form, so the IR carries no orientation. by quantile is the default.
  • The type slot names a pack observable, a [[references]] entry (energy.power_price), where the retired ref <id> clause stood. It gives the assumption its unit, asserted and never converted (E2310), and enters the assumption in the IR's required_refs (§17.3). An observable may type a value or a series the same way. An id no active pack declares is E2305. The type is optional: an untyped quantile is accepted in a model with no pack.
  • A quantile takes the clauses every assumption does (§12.1): within, which is checked on every value (E2307), and source; it may be a field of a set (§12.7). A malformed body is E5028_INVALID_QUANTILE; two of one name are E1005.

Reading. quantile_at(inputs.x, share), quantile_mean(inputs.x, from, to) and quantile_of(inputs.x, value) (§16.2), the assumption as the first argument, never a string. A quantile function has no single value, so a bare inputs.x, a quantile in an entity's = field, a quantile function over an assumption that is not a quantile, and the retired string spelling are each refused (E2312_ASSUME_SHAPE_READ).

A quantile is univariate. A joint declaration over two quantities would be a correlation, which §1.1.10 and §17.4 exclude from the core language and from the IR. It is also never sampled: it is declared data, and uncertainty about one is an ordinary assume scaling it.

12.7 Sets (named assumptions, read by path)

A set is a block of named assumptions, each of any shape — a value, a series, a quantile, a distribution, a string, or a set — stated once and read by full path:

assume retail_market : CRE.Reference.MarketLeasing = {
  market_rent_psf     = curve linear { 2026-01: 48.00  2027-01: 52.80 }
  renewal_probability : fraction = 0.65
  downtime_months     = 9
  new     = { term_months = 120  ti_psf = 60 "USD/sf"  lc_pct = 0.06  free_rent_months = 6 }
  renewal = { term_months = 60   ti_psf = 15 "USD/sf"  lc_pct = 0.03  free_rent_months = 2
              rent_share  ~ Triangular(min=0.90, mode=0.95, max=1.00) }
} source { publisher "Midtown retail broker survey"  as_of 2026-07-01 }

amount = inputs.retail_market.market_rent_psf * area / 12

One profile — a market's leasing terms, a pool's collateral assumptions, a deal's tax assumptions — stated once and read by many contracts. A set differs from a value in shape, not in kind: every field is an assumption, evaluated once at run start, shown on the review page on its own row, and reached by a scenario, an override or a draw by its full name (retail_market.renewal.rent_share).

  • A field takes the clauses a top-level assumption does — : <type>, within, a unit, source — and a field's own source overrides the set's. A set takes no within of its own (E2306): a bound is stated per field.
  • The type is optional. Untyped, a set is accepted in any model, with or without a pack. Typed by a pack's set type ([[reference_types]], docs/07 §6.6), the set is held to the type's roster where it is written: a field outside the roster, or a value where the type nests a subset, is E2308_ASSUME_FIELD_UNKNOWN; a value the field's type cannot take — a bare word, a fraction in a whole-number field, a string in a number field, a unit other than the declared one, a number outside the bound the field declares — is E2310_ASSUME_FIELD_INVALID. A type no pack declares is E2305.
  • A set declares no defaults. A field it leaves out has no value, and a read of it is refused (E2309_ASSUME_SET_READ) rather than given a pack's number the model never stated. The set itself has no single value: a bare read, inputs.retail_market, is refused with the same code, in an expression, a term, or an entity's = field — except in the two slots declared to hold a set, below.
  • A field's expression reads other assumptions by full path, never by a bare sibling name: renewal = { ti_psf = inputs.market.new.ti_psf * 0.25 }. A bare name resolves where the expression is read, not where it is written.
  • An entity's = field naming a single-valued assumption — a value, a distribution, a string, or a set field of one of those shapes — takes its value at run start (§7.1): an assumption is evaluated once, so it is a literal in effect. A set, which has no single value, cannot be held there.
  • In the IR a set's fields are entered by full path, each with its set and the pack type that typed it; the set itself carries no entry (docs/05). Two names that share a path are E1005.

A set may name a set. A field whose whole value is another set, renewal = inputs.standard_renewal, IS that set. Two sets can share one package:

assume standard_renewal = { term_months = 60  ti_psf = 15 "USD/sf"  lc_pct = 0.03 }
assume retail_market = { market_rent_psf = 48  renewal = inputs.standard_renewal }
assume office_market = { market_rent_psf = 60  renewal = inputs.standard_renewal }
  • inputs.retail_market.renewal.ti_psf reads inputs.standard_renewal.ti_psf. The compiler rewrites every read through the name, so the review page, a scenario and a draw see one set, and an override of the shared set reaches every reader (§12.1: assumptions are evaluated after overrides land).
  • An override addressed through the name, inputs.office_market.renewal.ti_psf, is refused (E5033), naming the set that states the field: it would be ambiguous between the shared set and this market's view of it. A market whose renewal differs states its own renewal = { … }.
  • A chain of names ends at a set that states its fields. A chain that returns to its start, or sets that name each other so each contains itself, is E2316_ASSUME_SET_NAMED.

A contract term may name a whole set where its type declares the term as a set type (docs/07 §6.3): market_leasing = inputs.retail_market. It is how a pack's rule, written once for every contract of its type, is told which set to read. A term naming anything but a set, or a set of another type, is E2316. A model with no pack has no set-typed term and needs none: it reads every set field directly, anywhere an expression is allowed.

12.8 Source (provenance)

assume cpi_growth : rate = 0.025
  source {
    publisher "Survey of Professional Forecasters"
    series    "CPIB10"
    as_of     2026-08-15
    retrieved 2026-09-11
    url       "https://www.philadelphiafed.org/surveys-and-data/"
    note      "Median ten-year CPI inflation forecast"
  }
fieldholds
publisherwho produced the data
seriesthe publisher's own identifier for it
as_ofthe date the data describes
retrievedwhen it was taken
urlwhere it was taken from
noteanything else a reviewer needs

Every field is optional; an unknown field, a repeated one, or a date field given a string is E2311_ASSUME_SOURCE_INVALID. source is a contextual word. A source is part of what the model states, so it is part of the IR the model hash covers: two models citing different sources are different claims about the same numbers. The results' inputs.assumptions is the review page (docs/06): one row per assumption, a set's fields each on their own row, with its shape, type, unit, value, origin — the model's statement or the run's override — and the source it cited.


13. Events (discrete model changes)

13.1 Event declaration

Syntax:

event refi_if_rates_drop when inputs.sofr < 0.045 {
  set entity asset.senior.status = "refinanced"
  deactivate stream loan.debt_service
}

An event may also state WHEN it occurs in the schedule language, with or without a when condition:

event covenant_test schedule every quarter
  when series_sum("plant.noi", time.t - 4, time.t - 1)
     < 1.20 * series_sum("plant.debt_service", time.t - 4, time.t - 1) {
  set entity asset.plant.status = "trapped"
}

A guard reads the walk's own past — series strictly backward, fields, curves — and a money subtotal strictly backward too: domain.*, or one entity's, entity.<symbol>.<subtotal>, over a window ending at time.t - 1 or earlier (§9). A period's subtotal is folded once the period's streams settle, and a guard settles before them, so a read reaching the period itself is refused (E2113); a stream may read it (§9). A ratio subtotal is a metric's to read.

Rules:

  • An event is something that happens, and nothing restricts it to happening once. A unit that defaults, cures and defaults again has had three events, and a model that can only record the first is wrong.
  • An event fires on each occurrence — rising edge. It fires each time its conditions become true having been false, and re-arms when they fall. A DSCR below trigger for twelve months is ONE breach event and twelve breach-months; "the conditions hold" is a state, and active in state (§9.3) and edge guards (§7.3) already express states.
  • A schedule supplies the occurrences; when filters them. An event may carry a schedule clause, a when clause, or both. With both present it fires at each scheduled occurrence where the condition holds — schedule every quarter when dscr < 1.20 is a quarterly covenant test, and four consecutive failing tests are four breach events, because the model declared quarterly testing. Rising-edge applies only where no schedule is present, occurrences then coming from the condition's own dynamics. A model wanting "only on entry into breach" has a direct spelling already: an edge on a machine, which is what states are for.
  • Time conditions belong in the schedule language. The schedule sub-language is already the language of when things occur — dates, intervals, anchors including state_enter, roll conventions, calendars. Write schedule on 2027-03, not when time.t == 15.
  • There is no once keyword, and nothing latches. Once-ness is a property of the world the model declared, and it has two spellings: a schedule whose occurrence is singular (schedule on 2027-03 fires once because the date occurs once), or a topology with no way back (a refinance fires once not because the event is latched but because afterwards the loan IS refinanced — current -> refinanced with no returning edge). If a model declares a return edge, it has declared that re-firing is possible.
  • Named and anonymous events work identically. To describe an event informally, use guard conditions on the machine (§7.3); to canonize one, use event. Same firing semantics, same evaluation environment, same journaling. The anonymous form suits entity-local conditions; the named form suits occurrences worth canonizing — referenced from elsewhere, or spanning entities.
  • when MUST be a boolean expression.
  • Conditions are evaluated once per period, in declaration order, in the walk's state stage. An event fires at most once per period, and at most one transition per entity per period is taken. Rising-edge detection compares this period's evaluation against the last; nothing re-evaluates within a period.
  • A status write is validated against the machine. set … status on an entity whose lifecycle declares edges is refused — with the edge named — where no declared edge permits the move; the refusal is journaled as declined, and an edge-less machine stays unconstrained.
  • Declaration order decides which event fires first within a period, and which write wins when two events set the same field.
  • A guard reads the state as the period OPENED. Every event and option in a period evaluates against the same frozen pre-state; writes accumulate and are visible at t+1. A STREAM, by contrast, reads the state as the period CLOSED, so a transition takes effect in the period it fires. That is the synchronous discipline: transitions all evaluate against the current state, the state commits, then outputs read the committed result. It is what keeps declaration order from changing the value of a guard.
  • A guard may read any entity field by qualified path — asset.tower.status, asset.pool.factor — since an event has no owner of its own. It may NOT read a stream.
  • Every write is published in deterministic.transitions — period, entity, field, from, to, and the firing event — so a transition is assertable.

13.2 Actions (v0.1 core)

Supported actions:

  • set entity <EntityRef>.<field> = <value>
  • activate stream <StreamName>
  • deactivate stream <StreamName>

activate/deactivate contract are not actions. A contract is a collection of streams — cre.lease lowers into base rent, recoveries and abatement — and one switch gives a single answer where forbearance (principal stops, interest accrues) and an early termination (rent stops, a fee flows, recoveries continue) need a per-stream one. Gate the streams themselves, by name below or with active in state (§9.3), which is checked and can end as well as begin.

A <StreamName> is any stream the model runs: one the model declared, or one a contract lowered (cre.lease.base_rent is §9.1's own example, and docs/07 §6.4 gives the identical string as an example of a generated name). Reaching for a contract does not cost the ability to stop its cash. A name matching neither is E1302.

  • exercise option <OptionName>

The same vocabulary is what an option's exercise DOES (§14.1): an option body carries these statements and runs them on each exercise.

13.3 Event timing and the grid (normative)

An event fires at each occurrence, evaluated once per period against the state as that period opened. Where the event carries a schedule, the occurrences are the scheduled ones the when clause admits; where it does not, an occurrence is the period its condition becomes true having been false. It cannot fire between periods.

The model calendar therefore bounds how precisely a condition can be met: a condition that becomes true partway through a period takes effect at that period's boundary, not when it became true. Where an event determines an allocation, the calendar is a term of the model and not a presentation choice.

13.4 Entity state in an activation guard

A stream is active on its effective dates: its schedule is what brings it into being, and §9.3 is normative on that — a guard is optional and its absence means active for every scheduled occurrence.

Where a stream's activity depends on something the model tracks rather than on the calendar, entity state is what a guard reads:

active when entity.status != "refinanced"

This is allowable, not required, and not a substitute for the effective dates. A contract takes no such guard. A contract records what was agreed and its term states when its obligations run; whether a right or an obligation is exercised is a modeling decision, carried by an event, an option, or the entity state those write — never by the record of the agreement.


14. Options (elections)

An option is a contract with an election: a right someone holds to make something happen, on stated terms. It is declared with the option keyword rather than contract because it lowers through no pack rule — its cash is the payoff of the election, resolved by the engine — but it carries what every contract carries: what it is written on, who it is between, and its terms.

14.1 Option declaration

Syntax (normative):

option call_at_120 on entity asset.plant type Option.Call {
  parties { holder = party.holder }
  terms { strike = 120.0 }
  exercise when asset.plant.book_value > contract.strike
  payoff asset.plant.book_value - contract.strike
}

Rules:

  • option <name> [on entity <EntityRef> | on contract <ContractName>] type <TypeId> [exercisable [<n> times] [in <phase>]] { parties / terms / schedule / exercise when / payoff / actions }. The body's items may appear in any order.
  • type names an election: a concrete refinement of Contract.Option in the active pack, or one of the four the language base carries so a model with no pack can write one — Option.Call, Option.Put, Option.Renewal, Option.Refinance. A type the ontology does not define is E1373; a master is E1374; a lowered type written as an option is E1373 with the hint to declare it with contract.
  • What it is written on. on entity names the asset the right is over. on contract names the AGREEMENT the right is over — a renewal is a right over a lease, a prepayment a right over a loan — and the option takes that agreement's entity as its own. A contract the model does not declare is E1376. An election type may state what it is written on (docs/07 §6.1, written_on): a lease option is a right over a Contract.Lease, a loan extension over a Contract.Debt. An option of such a type written on an agreement of another type, or on none, is E1390; the four generic elections state none and may be written on anything. Either form may be omitted, in which case the payoff belongs to no entity and falls out of every per-entity total.
  • Parties bind the roles the election type declares, checked against its master chain as a contract's are.
  • Terms are checked against the election type's effective fields exactly as a contract's terms are (§8.1): Contract.Option declares strike (optional), and a pack election adds its own. An unknown term is E1371; a required term omitted is E1372.
  • contract.<term> in exercise when or payoff reads a stated term — the option's own first, then, where the option is written on contract, the agreement's. The value is spliced at compile time, so a term may be a literal, an inputs. reference or an expression, as a contract's may. A read of a term nothing states is E1372: a read with no value is a missing term, never a zero.
  • exercise when is the election, evaluated once per period against the state as the period OPENED (§13.3), and may read entity fields by qualified path, its owner's entity state, and its owner's claims as prev.<account>; it cannot read a stream. Exercise is rule-based: the model exercises when the stated condition says so, never when a search over holder value says it should.
  • An exercise is an occurrence, in the event's sense (§13.1). With a schedule in the body — the same sub-language a stream's takes — the schedule supplies the occasions and the election filters them: a Bermudan right, exercisable on stated dates. Without one, an occasion is the election's rising edge while the option is held: true having been false, so a right that stays in the money is not re-exercised every period. Outside its exercisable in window the option is not held and its election is not observed, so a right whose condition already holds when the window opens is exercised as it opens. An event's exercise option forces an occurrence inside the window (§13.2), never outside it.
  • A right is exercised as often as it allows. exercisable 2 times declares the count; absent, once. A lease with two five-year renewals is exercised twice. Each exercise pays the payoff and runs the actions; the journal records exercise n of N.
  • A pack may state what an election does (docs/07 §6.4, options): the payoff, its category and the actions of an option type, templated from the option's terms and the agreement's. A model option of the type that states no payoff takes the pack's, and its category with it; one that states no actions takes the pack's. What a model states replaces the pack's.
  • An exercise does something. The body takes the action vocabulary of §13.2 — set entity, activate stream, deactivate stream, exercise option — run on each exercise through the same stores an event writes, with the same checks (an unknown stream is E1302, a status write is validated against the machine). A prepayment option ends the loan; a renewal extends the lease. A set value may read contract.<term> and prev.<account> as the election does. Without actions an exercise only pays.
  • payoff is the cash the exercise produces, published as a series under the option's name — zero where it did not exercise, so a non-exercise is assertable — and accumulated where it is exercised more than once. An account's inflow and a waterfall read it in the period it is paid, as option.<name>: a buyout's price joins the trust's collections in the period the buyout is exercised.
  • category says what the payoff IS, exactly as a stream's does (§9.1): category investing.capital.leasing on a renewal whose payoff is the landlord's leasing cost. The same three roots, the same check (E5022); a categorized payoff folds into the pack's subtotals and a slice by category, and the results publish the category beside the series. Optional: an uncategorized payoff folds into no subtotal, which is what a zero payoff wants.

15. Runs

15.1 Run declarations

run deterministic
run monte_carlo trials 20000 seed 42

Rules:

  • monte_carlo MUST provide trials and seed.

A trial IS a complete deterministic run, and every metric it computed is published: each trial summary carries the same metric map the deterministic block carries — model.*, domain.* and metric.* alike — and monte_carlo.metrics summarizes each name across the trials with a mean, a standard deviation, a minimum, a maximum and the full set of percentiles. A summary also states trials, the number that published that name, because not every trial publishes every one: model.irr exists only where the flows solve for a rate. A name a distribution cannot be taken over — a metric published as a string, or one whose kind changed between trials — is carried per trial and left out of the summary.

Per-trial SERIES are not retained: a stochastic run's output is bounded by the model's metric names and its trial count, not by its horizon as well.

15.2 Engine-computed outputs

Output metrics (NPV, IRR, DSCR, NOI, etc.) are computed by the engine based on the domain pack's output specification.

15.3 Model-declared metrics (normative)

A model MAY declare a metric — a figure it solved for that neither the engine nor a pack mints:

metric class_a_wal   = wal("notes.principal.class_a")
metric crossover     = metric.class_a_wal - inputs.expected_wal

Rules:

  • A metric name MUST be unique within the model (E1008).
  • A metric is evaluated ONCE, at the horizon, over the finished projection — the valuation plane. It is a fold over a completed projection, not a recurrence: nothing it computes can feed back into the walk.
  • Its expression MAY read series (including the projection tail, which is what a forward-looking figure needs), entity fields, inputs, the engine's model.* metrics, and metric.<name> for any metric DECLARED ABOVE IT.
  • The series it may fold are the ones this model PUBLISHES, in either spelling: a stream by its own name (ops.rev) or by its published key (stream.ops.rev), a waterfall step, entity.<symbol>.net_cash_flow, account.<name>, an entity field's own series, a subtotal, a declared slice's net as slice.<name>, and model.net_cash_flow. The two spellings of one stream name the same cash. A slice is how a metric folds cash selected by TYPE and LINE — every debt's interest — without naming a stream or a pack's category.
  • Undefined is not zero. A ratio subtotal's undefined periods — a coverage ratio with no debt service — publish as null, and a fold SKIPS them: they are not observations (docs/03 §4). series_min("domain.cre.dscr", 0, 59) is the covenant question, and it answers over the periods the ratio had a value in. wal alone does not fold a ratio, because it measures the life of cash paid (E1365). A stream that has not started is zero, not undefined; a metric whose fold has no answer publishes null.
  • A metric that folds a name this model does not publish is REFUSED (E1365), not read as zero. A selector with a * or alternatives may still match nothing, because matching nothing is what a selector states at its call site (§16.2, selectors); each exact alternative must name a series the model publishes.
  • Metrics compose in declaration order — the same rule waterfalls follow (§10.5) — so the dependency is an order rather than a graph. A forward or circular reference is refused (E1354).
  • Every metric is published as metric.<name> in deterministic.metrics, in every scenario summary, and in every Monte Carlo trial summary, so a scenario grid can assert a derived figure per column and a stochastic run gives that figure a distribution — not only the engine's built-ins.

A valuation of a selection. Two folds price what a selection of published series pays, from the open of a period to the cash horizon, each matched series at its own placement in the period:

assume class_price = 0.985
metric class_price_at_6 = npv("notes.*.class_a", 0.06)
metric class_yield      = yield("notes.*.class_a", inputs.class_price * contract.credit.note.a.face)

npv(selection, rate[, from_t]) is the present value at an annual rate, compounded to the calendar's period as model.npv is; yield(selection, outlay[, from_t]) is the annual rate at which that value equals outlay, the full price paid at the open of from_t (period 0 when absent). A price is an assumption, never a term or a stream: the ledger is cash before pricing. Both publish null when the selection matched nothing or no rate solves; both take a selection as wal does, and a ratio subtotal is refused (E1365): a ratio is not cash. Their argument shape is checked at compile (E1333).

A third fold prices a floater. spread(selection, outlay, inputs.<index>[, from]) is the annual spread over an index path at which what the selection pays from the outlay's date is worth outlay: the discount margin. The index is a series-shaped assumption, named as a reference and read at each period's open, the reset; the discount accrues simple between consecutive settlement dates at the index plus the spread, the basis a floating coupon accrues on, so a floater bought at par solves to its own margin whatever the index does, and a semiannual payer on a monthly grid accrues over six months rather than six compounded months. It publishes null as the other two do, and a period the index has no value at refuses the metric.

Where the outlay sits. The optional last argument of npv, yield and spread is a period, placing the outlay at that period's open, or a date (parse_date("2019-01-30")), placing it on the day: a settlement date, which may fall before the model's first period. Absent, it sits at the model start. Each matched series is measured from that date on its own measure, a security's day count where the agreement is quoted on a settlement-date basis and by period otherwise, and a cell paid before the outlay is not bought.

A return from a stated date, everything before carried at cost. A sponsor reports a deal it is already in from a date, on the whole life's cash with what came before carried at cost into one flow on that date. It is a yield whose outlay is that cost and whose date is the date: what the selection paid before it, summed undiscounted, and what it pays from it.

metric carried_at_cost = 0 - series_sum("equity.investor.*", 0, 32)
metric irr_from_oct    = yield("equity.investor.*", 0 - series_sum("equity.investor.*", 0, 32), parse_date("2026-10-01"))
metric npv_from_oct    = npv("equity.investor.*", 0.08, parse_date("2026-10-01"))

The carried cost sits at the date's open and the period's own cash at its placement, so on a coarse calendar the date's period is not netted into one flow. The multiple from the date is a ratio of two sums over the same selection.

A participant's realized return. Two more folds are available in a metric and nowhere else:

metric lp_irr  = irr(party.lp)
metric lp_moic = moic(party.lp)

What the fold reads is the party's own account: its contributions are the lines that lowered it — an inflow stated negative, or a stream that moves it — and its receipts the allocations in. A capital call written as a stream into the deal that moves the partner's account is therefore a contribution.

The party is a REFERENCE, not text. A party is an entity, named the way the language names entities everywhere else — pay … to party.lp, owner party.lp, on entity asset.x — and the reference is what lets the compiler resolve it: an undeclared name is E1301, an entity that is not a party or that owns no account is E1356. Text would defer all three to the run.

Both read the party's own ACCOUNT — a contribution is a negative inflow, a receipt is an allocation in, so the sign change an IRR needs is recorded rather than inferred. They are folds over the party's account and never over a payee's streams: a step's payee says who was paid, but attributing through stream names is a different question. A party owns at most one account.

Both are refused outside a metric (E1355): reading a return in a stream amount asks for a return on cash that stream has not produced yet. What cannot be known until the run — flows that never change sign — refuses the DETERMINISTIC run naming the party (E5031), because a metric the author declared must not silently go missing from the run the case asserts. A scenario or a Monte Carlo trial that cannot evaluate the metric OMITS the key from its summary and records the reason under omitted (docs/06): a partner wiped out in a downside is what the downside exists to show. A party that contributed and received nothing has a defined MOIC, 0.0; only its IRR has no answer.

The three namespaces stay distinct, and the prefix says who minted the number: model.* is the engine's, domain.* is the active pack's, metric.* is this model's.

15.4 Slices (normative)

A model MAY declare a slice — a named, deliberately partial selection of its own streams, with figures computed over the selection:

slice artist_a_royalties {
  entity asset.artist_a
  category "operating.revenue.royalty"
}

slice label_ex_merch {
  entity container.label
  except category "operating.revenue.merchandise"
}

slice debt {
  type Contract.Debt
}

slice debt_interest {
  type Contract.Debt
  line interest
}

slice west_2027 {
  entity asset.west_tower
  window from 2027-01 to 2028-12
}

Rules:

  • A slice name MUST be unique within the model (E1361).

  • A slice is cash: a non-cash stream, an accrual or a write-off, is in no slice, whatever its clauses select.

  • Clause KINDS intersect; values within a kind union; except subtracts last. A kind that is absent does not constrain, so a slice of nothing but excepts reads "everything minus these".

  • entity and except entity take REFERENCES — an undeclared entity is refused (E1362) — and select the entity together with its part of descendants, so a container's slice is its members'.

  • type names an ontology type, matched transitively through refines: type Contract.Debt selects every stream lowered from a contract whose type is_a Contract.Debt, and streams owned by entities of a conforming type. An unknown type is refused with the known types named (E1363).

  • line names a LINE BY ROLE — what a master says the agreement produces: line interest selects every stream a pack rule emits as its type's interest line, whichever pack and whatever category it spells. Beside type the two intersect: type Contract.Debt line interest is the interest of every debt. A line nothing produces is refused with the near miss (E1375).

  • category and stream take QUOTED selectors — the dialect series_sum reads (§16.2, selectors) — and a category selector must be rooted in a statement section (E1364).

  • A waterfall step is a series its waterfall's subject owns, selected like any other: by entity, stream, or by type and line where it pays an allocated line (§10.2). Every series is signed from its owner, so a step, what the subject pays out, is negative in any slice that selects it; the holder's receipts are the holder's account. A slice of a subject's cash before its split says so: except stream "<waterfall>.*".

  • window bounds the PERIODS, where every other clause bounds the streams. A period outside it contributes nothing, so total, npv, irr and moic are folds over the window. At most one window per slice.

    A window is not a phase. A phase is a lifecycle anchor — phase_start() and phase_end() drive schedules, and a period is named by the phase it sits in. A window is a reporting bound applied to a finished projection. Giving one construct both jobs would mean neither could change without the other.

    Dates, not period indices: an index is a fact about one grid, and a window that survives a change of calendar has to be stated in dates. A month-only bound means the first of that month, as a phase's does.

  • Results publish each slice's selection (the lineage), the streams it matched, its net per-period series, and total/npv/irr over the matched streams on the model's own axis. A slice carries no reconciliation block: it is partial by design, and must be seen to be — a slice never publishes a residual and never claims the model's total.

See the Pack Interface specification for details on how packs define output categories, aggregations, and metrics.


  • A pack may declare slices (docs/07 §6.10). They are emitted beside the model's own, and a model's slice of the same name replaces the pack's.
  • A slice is a VIEW, and changes no identity. It filters a completed result; it produces no cash. The compiler files it under the document's views, which model_hash is taken over WITHOUT — so two users who look at identical results differently share a model hash, and a slice moves neither hash. A declared METRIC is not a view: it is a figure the model claims, so it belongs to the model and does move model_hash.

15.5 Statements (normative)

A model MAY declare a statement — how its results are organized. A statement enumerates NO rows:

statement portfolio {
  label     "Portfolio by property"
  structure entity
  depth     2
}

statement operating {
  label     "Operating statement"
  structure category
  depth     3
  slice     west_2027
  metrics   noi_yield, lp_irr
}

Rules:

  • A statement name MUST be unique within the model (E1366).
  • structure names an existing hierarchy: entity, the part of tree the results graph publishes, or category, the dotted category path. A structure the engine does not build is refused (E1367), as is a category statement in a model whose streams declare no category — either would render one residual row and nothing else.
  • Generated rows are ordered so the result reads as a hierarchy: DEPTH FIRST for an entity structure, so a parent is followed by its own subtree rather than by every other node at its level. Category ROOTS follow the canonical order — operating, investing, financing, the order a cash flow statement is read in — and siblings below a root sort alphabetically, which is arbitrary but stable.
  • A generated row's LABEL is derived from the name it was generated from: the last path segment, underscores opened out, first letter capitalized, so operating.revenue.base_rent reads "Base rent". An authored row states its own.
  • depth sets the LEVEL OF AGGREGATION, and the rows follow from the tree. A subtotal is not declared. A node whose children are shown is a subtotal; a node whose children are cut off by depth is a line, carrying all of its descendants' cash. That single rule is what keeps the bottom line reconciling at every depth: the lines always partition the cash, whichever level the tree is cut at.
  • slice filters, orthogonally to the structure — any structure may be shown for any filter. A statement so filtered reconciles against the SLICE's total rather than the model's, because reporting the filter as a shortfall would make a warning fire on a correct model, and it covers only the categories the slice selects: sources and uses is the capital, not the operations.
  • A statement folds CASH. A non-cash stream — an accrual or a write-off, which moves an account and enters no cash total — is in no row and no residual, so every statement reconciles to the model's total.
  • metrics names declared metrics to publish beside the statement. A metric is one number at the horizon and every row is a series, so the figures sit in their own map rather than as a row kind. An undeclared slice or metric is refused (E1368). A statement may state its own rows instead. A generated statement is right when the tree IS the presentation; a pro forma is not that, because its rows carry curated labels, its expenses are shown positive under "Less:", and it ends in a coverage ratio that is a node of no hierarchy.
statement operating {
  label    "Operating statement"
  line     "Base rental revenue"   { category "operating.revenue.base_rent" }
  line     "Less: operating costs" { category "operating.expense.*" display positive }
  line     "Less: interest"        { type Contract.Debt line interest display positive }
  subtotal "Net operating income"  { category "operating.*" }
  spacer
  ratio    "DSCR"                  { of noi to debt_service display positive }
}
  • A statement is AUTHORED OR GENERATED, never both, and never neither (E1369). A generated statement partitions the cash by construction; an authored one partitions it by the author's care. Mixed, neither holds.

  • A row draws from category, stream, type, line, slice or entity. type and line mean what they mean on a slice — every debt's interest, whichever pack lowered it — and the compiler expands them to the exact streams the row claims, so the bottom line reconciles as it does for a category row (E1363, E1375 as on a slice). A subtotal row folds rows stated elsewhere and CLAIMS nothing, so it never doubles the bottom line.

  • A row may instead draw a published series (series "domain.cre.noi") — a fold OF the ledger rather than cash in it, so the row claims no streams and its figure stays out of the bottom line. A selector sums every series it matches, series "metric.cre.affordable_covenant.*.affordability_gap", so a pack's statement shows a figure its contracts publish per instance. A claim clause beside a series is refused (E1370), and a key this run does not publish renders no values and no total rather than a column of fabricated zeros.

  • A ratio divides two declared SLICES — a slice is already a named selection with a per-period net, so a ratio needs no row identifiers. A zero denominator publishes null, not zero. A ratio carries no total, because summing one means nothing.

  • display says how to RENDER the sign and never what is summed: values carries the signed amount, so a consumer that ignores it still adds up correctly. An outflow is negative cash, so a coverage ratio is arithmetically negative and display positive is how it is shown.

  • A row's depth is an indent. The STATEMENT's depth is a level of aggregation — an authored row states where it sits, a generated one is told by the tree.

  • Every clause word is CONTEXTUAL; only statement is reserved.

  • A statement is a VIEW. It changes no value and no identity: the compiler files it under the document's views, which model_hash is taken over without, so adding a statement moves neither hash. Views may be declared beside the streams they present, or kept in their own file and imported.

A pack's statements and slices are declarations. A pack declares statements and slices with every clause a model's take (docs/07 §6.10), and the compiler emits them into the document's views beside the model's own, ahead of them, as if the model had written them. They are checked as the model's are and render through the one evaluator, for every model that uses the pack. A model's statement or slice of the same name replaces the pack's, and no diagnostic is raised: the pack is a shortcut, never a ceiling. As views, they move neither hash.

A ratio row divides two operands, each a declared slice or a subtotal the active pack publishes (E1368 otherwise).

A model that declares no statement gets one. When neither the model nor a pack provides a presentation, the entity hierarchy is rendered as a default, marked default, so a reader holding results sees the model's shape rather than a flat list of series keyed by symbol. It is assembled when results are rendered and never enters the compiled document, so it changes neither hash; and it yields to any declared statement, because a declaration means the presentation question is already answered.

A statement is a fold; a table over the graph is not a statement. A statement is a fold of the ledger the model claims: rows that partition its cash and reconcile against the model's total. A table over the graph is a projection of what the results already publish, and it needs no declaration. A rent roll, a tranche table, a loan tape, a debt schedule and the per-class rows an investor report opens with are tables of this kind. graph.contracts carries each contract's type, master, term, currency and parties, its terms as the run resolved them with the type and unit its pack declares, and the streams lowered from it; graph.entities carries each entity's fields the same way; and each stream series names its contract and its line (docs/06). Such a table belongs to the renderer and changes neither hash. A report element that needs a fold the results do not carry calls for a new statement structure, not for a report construct in the language.

16. Expressions (CFDL expression language)

16.1 Expression syntax

Expressions are written as:

<expression>

Expressions MUST be:

  • side-effect free
  • deterministic given the same inputs
  • terminating

16.2 Namespaces and built-ins (normative)

The expression environment MUST support:

Model/time

  • time.t (0-based period index), time.date, time.phase

Inputs

  • inputs.<name> for assumptions (fixed or stochastic), and inputs.<set>.<field> for a set's field (§12.7); a set has no single value and a bare read of one is refused

Entities

  • entity.<field> on the owning entity, and <family>.<entity>.<field> from anywhere — entity fields and lifecycle state (an entity with a lifecycle opens in its declared initial state; other event-set fields are null before first set)

Series

  • inputs.<name> — a series-shaped assumption at the reader's period, and curve_value(inputs.<name>, <date>) the same series at another date (§12.5)
  • wal(<series>[, <from>, <to>]) — the weighted average life of what a published series paid, in years on the axis model.wal_years uses: each period's amount at its placement, over the total. A waterfall step bound to a contract (for contract … line …) measures instead from that contract's term start, on its own day_count, to the day the waterfall pays — the market definition of a published life. Null when nothing was paid — a life of nothing is not zero years.
  • npv(<selection>, <rate>[, <from>]) and yield(<selection>, <outlay>[, <from>]) — in a metric only (§15.3): the present value of what a selection paid from <from>, a period's open or a date, at an annual rate, and the annual rate at which that value equals an outlay paid there. Null when nothing matched or no rate solves.
  • spread(<selection>, <outlay>, inputs.<index>[, <from>]) — in a metric only (§15.3): the annual spread over the index path at which what a selection paid from the outlay's date is worth an outlay paid there, the discount margin of a floater. <from> is a period or a date. Null when nothing matched or no spread solves.
  • quantile_at(<name>, <share>), quantile_mean(<name>, <from>, <to>), quantile_of(<name>, <value>) — lookups into a declared quantile
  • ref.<name> is reserved for ontology references (not in the v0.1 dialect)

Selectors (normative). A quoted selector, wherever the language reads one — a series reduction, a slice's or a statement's stream and category, pay for contract — is one or more ALTERNATIVES separated by |, and a name matches when any alternative does; a series two alternatives both match is one series. An alternative is exact, or ends in .*, which matches the prefix and everything under it at any depth. Any segment may be *, which matches exactly one segment:

metric peak_debt = series_max("account.*.*.balance", 0, 59)
metric principal_wal = wal("credit.loan.sched_principal.* | credit.loan.prepay.*")

account.*.*.balance is every subject's balance, and not the per-instance balances beneath them, so nothing is counted twice. A selector with a * or a | may match nothing; an exact alternative names a series that must exist (E1365 in a metric). A * inside a segment, an empty segment or an empty alternative is refused (E1399). A selector with a * or a | folds cash, as a subtotal and a slice do: it does not match a non-cash stream, an accrual or a write-off, which moves an account and no cash. An exact alternative still reads the one it names.

Cross-stream series

  • series_sum(<pattern>, <window>) / series_avg / series_max / series_min / series_prod / series_count — cross-stream reductions (dependency-ordered waves; cycles rejected). Every one folds the per-period aggregate of the matched streams. Over a selection that matches nothing, series_max and series_min return null (nothing has no maximum), where series_sum, series_prod and series_count return their identity and series_avg averages the zero aggregate to 0 (docs/03 §4)

Math and finance

  • Standard arithmetic + - * / ^, comparisons, and/or/not, if(cond, a, b)
  • min/max/sum/avg/abs/round/round_down/round_up/clamp/pow
  • pmt/ipmt/ppmt/rate/nper/pv/fv (Excel sign conventions; npv and irr are metrics computed over results, not expression functions)
  • year_frac/eomonth/edate/date/parse_date/months_between
  • is_business_day/roll/add_business_days with named holiday calendars
  • macrs_rate, cpr_to_smm
  • ln, exp, normal_cdf
  • curve_value, series_sum, series_avg, series_max, series_min, series_prod, series_count
  • quantile_at(inputs.<name>, share), quantile_mean(inputs.<name>, from, to), quantile_of(inputs.<name>, value) — reads of a quantile-shaped assumption (§12.6)
  • The authoritative function catalog is 03_expression_environment.md

16.3 Currency literals

The language MAY support syntactic sugar:

  • 42000 USD as Money
  • 10% as Rate

These MUST compile to typed values in IR.


17. Canonical JSON IR requirements (high level)

17.1 Single IR

  • The compiler MUST emit one canonical JSON IR.

17.2 Preserve provenance

  • The IR MUST preserve:
    • all entities
    • all contracts (including terms)
    • all streams (explicit and derived)
    • provenance links from derived artifacts back to their contract source

17.3 Required external inputs

  • The IR MUST list what the model needs from outside, as required_refs: every assumption whose type slot names a pack observable (§12.6) and every bodiless assumption (§12.1), by full name and type, each recording whether the model states a fallback. A pack, a connector or a reviewer reads it as the list of inputs the model is built on, and the inputs file is keyed by these names.

17.4 No correlation

  • The IR MUST NOT contain any correlation field/slot.

18. Reserved keywords (v0.1)

The lexer reads every word below as a keyword before it knows the position, and the list is exhaustive: it is checked against the lexer, so a word added to one appears in the other. A reserved word is nonetheless a NAME wherever the grammar admits IDENT — an entity, a field (use = "office", state init 1 next prev), an assumption, a phase, a quantile, a slice, a statement, a metric, an account, a role in a parties block (owner = party.sponsor), or the target of set — because in a naming position a keyword has no other reading; the clause that follows tells a field from the state and account clauses of an entity block. In expression position the expression grammar governs, and there a reserved word is not a name.

18.1 In use (92)

Read by a production of the grammar:

account, accrual, activate, active, also, annual, as, assume, calendar, clip, contract, convention, currency, curve, daily, day, days, deactivate, deterministic, effects, end, entity, eom, event, every, except, exercisable, exercise, false, following, for, from, import, in, inflow, LogNormal, metric, mid, model, modified_following, modified_preceding, monte_carlo, month, monthly, months, moves, net, none, Normal, on, option, owner, outflow, pack, parties, payment, payoff, phase, phase_end, phase_enter, phase_start, preceding, quantile, quarter, quarterly, run, schedule, seed, set, slice, state, start, statement, stream, stub, tags, term, terms, time, to, trials, Triangular, true, type, Uniform, use, version, waterfall, week, when, writeoff, year.

Two of these are read only where a model writes them, to refuse them with a message of their own: a schedule has no stub policy (§11.2.6), and a contract has no tags block (§8.1).

18.2 Reserved, read by no production (12)

Reserved so that adding the feature later does not break a model that had used the word as an identifier. Writing one today is an error, and no syntax accepts it:

direction, Fri, long_back, long_front, Mon, Sat, short_back, short_front, Sun, Thu, Tue, Wed.

Mon through Sun were to anchor a weekly schedule to a weekday. That syntax was REMOVED BY DECISION, not deferred: schedule ... on <weekday list> is rejected and is not in the grammar, as is stub <policy>, which went the same way for the same reason. on accepts day <n> or eom. The words stay reserved so that reopening the decision could not break a model that had meanwhile used one as a name — which is what this section is for — but nothing is pending behind them. A weekly CADENCE is unaffected and works: schedule every week compiles and lowers to an every: "weekly" schedule. What has no spelling is the weekday it lands on, which today follows from the model's start date. direction and owner name parts of a stream header the parser reads positionally. The four stub conventions are reserved: the grammar admits none of them, and writing one is an error.

pay is contextual — it introduces a waterfall step and is an ordinary identifier elsewhere, and so are lifecycle, initial (§7.3), state_enter and periods (§11.4): position disambiguates each, and no model loses an identifier to them. remaining, paid and owed are bindings the host provides inside a step expression (§10.3), not keywords, and available and prev.<account> (§10.6) are the same kind of thing.


19. Minimal multi-file example (Core)

This example compiles and runs against the cre pack as written.

model.cfdl

version 0.1
model "sunset-apartments"
use pack "cre" version "0.1.0"

import "time.cfdl"
import "structure.cfdl"
import "assumptions.cfdl"
import "behavior.cfdl"
import "runs.cfdl"

time.cfdl

time calendar monthly from 2026-01 for 72
phase construction from 2026-01 to 2026-12
phase operations from 2027-01 to 2031-12

structure.cfdl

entity asset sunset
entity asset senior : Asset.Financial

assumptions.cfdl

assume base_rent = 42000
assume rent_growth ~ Normal(mean=0.03, stdev=0.01, clip=[-0.02, 0.08])

assume sofr = curve linear {
  2026-01: 0.050
  2028-01: 0.038
}

behavior.cfdl

contract cre.lease on entity asset.sunset {
  term 2027-01..2031-12
  terms {
    rent = inputs.base_rent
  }
}

stream loan.debt_service on entity asset.senior outflow currency USD {
  active when entity.status != "refinanced"
  schedule every month from 2026-01 to 2031-12
  amount = -pmt(0.06 / 12, 72, 8500000)
}

event refi_if_rates_drop when inputs.sofr < 0.045 {
  set entity asset.senior.status = "refinanced"
  deactivate stream loan.debt_service
}

runs.cfdl

run deterministic
run monte_carlo trials 20000 seed 42

20. Conformance

An implementation conforms to CFDL v0.1 Core if it:

  1. Parses valid CFDL programs per this spec.
  2. Rejects invalid programs with actionable diagnostics.
  3. Validates strong types and required fields.
  4. Emits deterministic canonical IR that preserves contracts/streams/provenance.
  5. Supports the schedule primitives and discrete event semantics.
  6. Supports CFDL-native expressions and the required namespaces/functions.