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.

CFDL Domain Pack Interface v0.1

Domain packs provide additions and overrides on top of a single core language: each pack adds contract types, lowering rules, metrics, and validations without forking the language itself.

Core principle: Packs may extend validation and provide defaults/templates, but MUST NOT change core language semantics.

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 document is the contract a pack author writes against. That is the reason the distinction is stated rather than assumed: a pack author has to be able to tell a requirement from advice without inferring it from the surrounding sentence.


1) Overview

A pack is a versioned module that can:

  • Provide type registries (domain entity/contract/option types)
  • Provide aliases (domain names → canonical core concepts)
  • Provide contract term schemas and lowering rules (contracts → streams/events/options)
  • Provide validations (domain constraints with stable diagnostic codes)
  • Provide defaults (required observables, output specification, reporting conventions)

Non-goals

  • Packs do not change core syntax.
  • Packs do not add nondeterministic behavior.
  • Packs do not embed external network calls.

Why packs (and why not bake domains into core)

CFDL core must remain simple, strongly typed, and stable. Domain logic — contract forms, regulatory constraints, industry assumptions — changes frequently. Packs isolate that volatility.

Determinism rules

Packs must be deterministic:

  • same inputs ⇒ same lowered outputs
  • stable ordering in any emitted lists
  • no random/time/network access

2) Pack selection in CFDL

CFDL models MAY select a pack:

use pack "cre" version "0.1.0"

Compiler rules:

  • use pack MAY appear only in model.cfdl.
  • At most one pack MAY be active in v0.1.

If no pack is selected:

  • Compilation still works using core rules.
  • Unknown type IDs are permitted (with warnings) except where the compiler is configured to require a pack.

3) Pack identity and versioning

3.1 Pack ID

A pack MUST have a stable ID string:

  • The ID is the bare pack name matching name in pack.toml (e.g., cre, energy, credit, opco); use pack "<id>" resolves against it. Namespaced ids (publisher/name) are reserved for a future multi-publisher registry.

3.2 Pack version

A pack MUST have a semver-like version string:

  • MAJOR.MINOR[.PATCH]

3.3 Compatibility

  • Compiler version and pack version are independently versioned.
  • A pack declares which model calendars it supports via cadences (§5.1). It does NOT declare supported compiler IR versions: earlier revisions of this page said it MUST, but no such field has ever been read or shipped. See the note under §5.1 on fields this page once described that do not exist.

4) Pack distribution formats

A pack MAY be distributed as:

  • a local directory (dev mode)
  • a signed bundle file (zip/tar)
  • a registry artifact (future)

v0.1 minimum: local directory packs.


5) Pack structure on disk

Packs are loaded from the filesystem (v0.2 default). Example structure:

packs/
  cre/
    pack.toml
    aliases.toml
    templates.toml
    lowering/
      rules.toml
    metrics.toml
    statements.toml
    validations.toml
    ontology/
      types.toml
    README.md
  opco/
    pack.toml
    ...

5.1 Required manifest (pack.toml)

A pack directory MUST include pack.toml:

name = "cre"
version = "0.1.0"
description = "Commercial Real Estate domain pack"
cadences = ["monthly"]   # optional; empty or absent means every calendar

[entrypoints]
aliases = "aliases.toml"
templates = "templates.toml"
lowering = "lowering/rules.toml"
metrics = "metrics.toml"
statements = "statements.toml"
validations = "validations.toml"
ontology = "ontology/types.toml"

Rules:

  • name and version are REQUIRED. description is optional.
  • cadences is optional and lists the model calendars the pack's rules lower correctly on (daily, monthly, quarterly, annual). Omit it, or leave it empty, and the pack is unconstrained — so a third-party pack that says nothing is unaffected. Declare it when the expressions assume a period length: a rule that divides an annual figure by a literal 12 is only correct on a monthly grid, and on any other one the schedule adapts while the amount does not. A model on an unlisted calendar is E5013_PACK_CADENCE_UNSUPPORTED. A single rule may narrow this further with its own cadences (E5014_RULE_CADENCE_UNSUPPORTED), which is what lets a pack carry neutral and month-locked rules side by side mid-migration.
  • Every entrypoint is optional; a pack supplies only what it defines. The recognized keys are aliases, templates, lowering, metrics, statements, validations and ontology, each a path relative to the pack directory. An unrecognized key is accepted and ignored, so check spelling: packs/cre/pack.toml once declared defaults = "defaults.toml", which the loader has no field for, and the file sat unread.
  • version is matched against the model's use pack "<name>" version "<v>" by exact string equality — there is no semver range logic. A pack present at a different version reports E4004_MISSING_PACK naming both versions, not a bare "not found".

Earlier revisions of this page described pack_id, ir_versions, entrypoints.types, contract_schemas, outputs and docs. The loader has never read any of them and no shipped pack declares them; a manifest written to that description would load with no entrypoints at all.

5.2 Pack formats

  • All pack artifacts are TOML-based.
  • Keep pack files deterministic and avoid mixing YAML/JSON variants in the same pack.

6) Pack capabilities (what a pack can provide)

6.1 Type registry (ontology types)

A pack MAY define types used by:

  • entity ... : <TypeId>
  • contract <TypeId> ...
  • option ... type <TypeId>

Required:

  • A pack MUST provide a type registry file for at least the types it claims.

Minimum shape — ontology/types.toml, the file the loader reads (an earlier revision showed a JSON registry no loader ever parsed):

[pack]
ontology_id = "cre"
version = "0.1.0"

[[entities]]
type_id = "CRE.Asset.RealProperty"
refines = "Asset.Real"

[[entities.fields]]
name = "rentable_area"
field_type = "decimal"
required = false
unit = "sf"

[[relations]]
relation_id = "occupies"
from_family = "party"
to_family = "asset"
cardinality = "many_to_many"
inverse = "occupied_by"

Type registry semantics:

  • Packs MUST NOT remove core types.
  • Packs MAY extend fields.
  • Packs MAY provide documentation strings and examples.

Refinement (refines). A pack type declares the master type it specializes — the language base's, or another type in the same pack:

[[entities]]
type_id = "CRE.Asset.RealProperty"
refines = "Asset.Real"

[[entities]]
type_id = "CRE.Asset.Unit"
refines = "CRE.Asset.RealProperty"   # chains are fine; they end at a master

Recorded rather than conventional, so "is a" is a fact the system can read: selection by a base type reaches every refinement transitively (PackOntology::is_a, called on the base-merged view), and a metric or validation written against Asset.Real survives a new pack unchanged.

Rules, checked at pack load:

  • The target MUST exist — in this pack or the language base.
  • Single parent, no cycles. A chain ends at one of the language base's masters — Asset.Real, Asset.Financial, Asset.Intangible, Party, Reference, or a Container.* — so every pack entity type states refines.
  • The family and the class are what the chain implies. A type states neither: its family is the master's — asset under Asset.*, party under Party, reference under Reference, container under Container.* — and an asset's class is the master's name (Asset.Real is real). A family or class key in types.toml is refused at load, and so is a type whose own name claims a family its chain does not reach (CRE.Party.X refining Asset.Real): what a thing is does not change by specializing it, and a reader who learned the kind from the name must not be lied to by the chain.
  • Fields inherit down the chain. A refinement carries every master field without restating it, and the compiler checks a model against the EFFECTIVE roster — required fields, near-miss detection and the declared list all include the masters'. A redeclared name is the same fact restated: a refinement MAY strengthen it (an optional master field becomes required — the move CRE.Asset.Unit makes on rentable_area) and MUST NOT retype, re-unit, or weaken it. A reader who learned a field from the master must not be lied to by the refinement.

Families. An entity declaration takes one of four families — asset, party, container, reference — and the graph holds five node families: those four plus contract, which is a node (a relation endpoint, identity-bearing) though it is declared with contract rather than entity. A reference entity is the owner of a computed market path (docs/01 §7.1): the base type Reference names level, index and value, and a pack refines it when it wants a unit on one. A pack's [[references]] registry (§6.6) is a different thing under the same word: it types the STATED series an assumption carries, assume cpi : cre.inflation, which the entity may then compound. A container groups and scopes — a fund, a portfolio, an SPV, a transaction. It holds cash-producers; cash attached directly to it is deal-level cash, real and aggregated with its members'. Only assets carry a class, and a pack states neither the family nor the class: both are read from the master its type refines, and the compiler checks a model's family word against the type's (E1319, E1323). The language base ships Container.Fund, Container.Portfolio, Container.SPV and Container.Transaction for packs to refine.

Relations. An endpoint names one node family or a list (from_family = ["asset", "container"]); a listed pair is a cross product. The base vocabulary: part_of/contains (hierarchy AND containment — one concept, widened endpoints), owns/owned_by, secured_by/secures (contract→asset, collateral), guarantees/guaranteed_by and is_counterparty_to/has_counterparty (party→contract). Base relations are declarative today: validated, published, no engine semantics.

Contract types take the same field, and the language base ships the abstract masters they refine: Contract.Debt, Contract.Lease, Contract.Purchase, Contract.Sale, Contract.Supply, Contract.Service, Contract.Tax, Contract.Option, Contract.Derivative, Contract.Insurance, Contract.Security, Contract.Equity, Contract.Royalty, Contract.Grant, Contract.Guarantee, and for the statement-line generators Contract.Revenue, Contract.Expense and Contract.CapitalExpenditure. A master is abstract = true: it exists to be refined, binds no lowering rule (refused at load if it does), and cannot be instantiated — a model that names one on an option is refused. The roster is indicator-based and extensible; absence of a refinement in today's packs is not evidence a master is unneeded.

A master is defined from what the agreement IS, and a pack conforms to it. A master declares its roles, its fields (the terms — there is no separate term schema), the LINES of cash its refinements produce, and which SIDE the subject is on. A refinement inherits all of it, may strengthen a field or add one, may specialize a role (landlord refines lessor; a domain word never appears on a master), may add lines, and may not retype, re-unit, weaken or drop anything the master declared. A master owns every shared term: a term two or more refinements of one master declare, and the master does not, is refused when the packs load — across every pack at once, because a base master's refinements live in different packs. A term every refinement needs is the master's; a pack adds a term only for what its refinement alone has; and a term some refinements share is declared once on an abstract type those refinements refine (CRE's PhasedLease and PercentageRentLease), never on a master whose other refinements would accept it unread. A master field restated to strengthen it is not a second spelling and is exempt. Pack load checks that a concrete type's rules emit every effective line (each rule names its line) and that its template renders every required effective field. Categories stay the pack's (§6.10): the master says a debt produces interest, the pack says where a borrower's interest sits in the statement.

Settlement-date basis. A contract type MAY state settlement_date_basis = true: agreements of the type are quoted on a settlement-date basis, so the engine measures their series from the settlement date to each payment date on the agreement's day_count, the way a security's lives, prices, yields and spreads are quoted (docs/01 §15.3). The security master sets it; a refinement may state it either way; a type that states nothing takes its master's, and a chain that states nothing is measured by period, as every other agreement is.

What an election is written on. An election (a refinement of Contract.Option) MAY state written_on = "<contract type>": the type the agreement it is over must be, read with is_a, so a lease option states written_on = "Contract.Lease" and a loan extension "Contract.Debt". A model's option of the type written on contract an agreement of another type, or on no agreement, is refused (E1390, docs/01 §14), and a builder offers it only the agreements of that type. A refinement inherits it and may narrow it to a refinement of its parent's. Load refuses it on a type that is not an election, naming no contract type, naming an election, or widening the parent's. A chain that states none admits any agreement, an entity or nothing, as the language base's four generic elections do.

Lifecycles. A type MAY declare a lifecycle — the same finite state machine a model declares with a lifecycle block: the core has the full functionality, and a pack tailors it to its domain. In types.toml:

[[lifecycles]]
lifecycle_id = "cre.unit"
initial = "vacant"
states = ["vacant", "leased", "downtime"]

[[lifecycles.transitions]]
from = "leased"
to = "downtime"
guard = 'series_sum("cre.rent", time.t - 1, time.t - 1) < 50'
  • A transition without guard is a permission: an event's write may take it, and the machine never fires it on its own. Every edge shipped before guards existed is this kind, so no pack changed meaning.
  • A guard makes the edge self-driving, evaluated each period an entity of the type is in from — reading series strictly backward, the same rule a model-declared edge's when follows, checked at compile.
  • A lifecycle that declares no transitions at all is unconstrained.
  • An entity whose type declares a lifecycle MUST NOT also bind a model-declared one (E1350): one machine per entity.
  • [[lifecycles.runs]] says which line roles run in a state (state, lines), as a model's in <state> run … does (docs/01 §7.3.3).
  • closes = ["disposed"] names the states that CLOSE the subject: from the period after it enters one, no line on it or on anything that is part of it runs inside the cash horizon, whatever the line is. A sold asset earns nothing for its seller, and a list of roles could forget one. The projection tail reads the states as the horizon ends, so what was sold during the hold stays sold and a sale in the horizon's last period leaves the year after it open for its price. A sale earlier in the hold whose contract publishes a valuation reaching past its date (reach_years, §7) gives the subject its own tail: the lines are evaluated that far after the close, without cash, for that valuation's figures (docs/01 §7.3.3). A closing state the machine does not declare is refused at load.
  • A contract type MAY state what it does to its subject when its term ends, [[contracts.on_end]] with set, value and a selection on its terms (when, defaults) as a rule has, as a model's on end does (docs/01 §8.5). A sale of the whole asset disposes it: set = "status" value = '"disposed"' when = { share = ["1", "1.0"] }. It applies only where the subject's machine has the state it writes, after the model's own on end. A write of the state the subject is already in moves nothing and warns nothing.
  • A lifecycle MAY refines a master machine (core.debt), renaming the master's states with [[lifecycles.refined_states]] (name, refines) and stating no states or initial of its own; its transitions, arrival actions and run lists add to the master's (docs/01 §7.3.4). A word for a state the master does not have is refused at load.
  • [[lifecycles.includes]] (state, machine) nests a machine inside a state, as a model's state <name> includes <machine> does (docs/01 §7.3.5); the state must be one the lifecycle has and the machine one the pack or the language declares, refused at load otherwise.
  • A contract type MAY bind a machine with lifecycle = "<id>" — a master machine or a pack's refinement of one — and its own refinements inherit the binding. The contract is then the machine's subject (docs/01 §8.6).

Arrival actions. A machine MAY carry what happens on arrival — the same two grains a model declares:

[[lifecycles.entry_actions]]
state = "leased"
description = "True of the STATE however it was reached."
actions = [{ set = "months_in_state", value = "0" }]

[[lifecycles.transitions]]
from = "holdover"
to = "leased"
actions = [{ set = "in_place_rent", value = "prev.in_place_rent * (1 + inputs.bump)" }]
  • Entry actions carry what is true of the STATE however it was reached, and are the primary domain spelling: a pack declares them once and every entity of the type inherits them, including for an edge added later.
  • Transition actions carry what is true of the PATH taken. A renewal and a re-let both land in leased and strike rent differently; an entry action cannot say that, because it does not know which edge fired.
  • Both run on EVERY traversal, including one a model's event causes by writing status across a permission edge. Entry actions run first, then the taken edge's — the specific refines the general — and a same-field write journals the earlier value overridden, naming its author.
  • set writes a FIELD and never status, refused at pack load: a status write would fire a second transition inside the same period. A transition that should cause another transition is an edge out of the target state, taken next period.
  • The field name is entity-relative, refused at pack load if qualified: one lifecycle is bound by many entities, and the behavior belongs to whichever one transitioned.
  • set may name the ACCOUNT a master declares: set balance = 0 on an entity's machine. The entity carries <entity>.balance because its contract opened it, so the write is a movement of the opening balance to that value — a write-off on repurchase — journaled as a move from the machine, and every row reading prev.balance that period reads the new opening. A FIELD ROLE (field_role = "balance" beside field_name) is the narrower companion: a private field the machine ends together with the account — the lagged twin a loan's recoveries read, so a repurchased loan recovers nothing for its seller. The compiler resolves the name per entity to the account and to every field that plays the role; a name nothing on the transitioning entity carries is refused (E1359) for a model's action and is a no-op for a pack's.
  • The field itself comes from the pack's lowering rules, like every other pack-populated field (field_name / field_init / field_next in §7). Those run per contract instance, so whether a given entity has the field is a fact about the model, not about the pack — an action naming a field the entity does not have is skipped with a warning at run.
  • A model MAY add its own actions to a pack's machine, additively: a lifecycle <pack machine> block contributes actions and may not state initial, state or an edge (E1357). The model's actions run after the pack's, so the model wins a same-field conflict and the pack's value is what journals overridden.

6.2 Alias registry

Aliases map domain-friendly names to canonical TypeIds or contract templates.

Example:

{
  "aliases": [
    {"alias": "Lease", "resolves_to": "Contract.Lease"},
    {"alias": "SeniorLoan", "resolves_to": "Contract.Loan.Senior"}
  ]
}

Compiler usage:

  • Aliases are used by editors/CLI for suggestions.
  • Aliases MAY be expanded during lowering if present in source.

Rules:

  • Alias resolution must be deterministic.
  • Packs must not create ambiguous alias collisions within a single loaded environment.

6.3 Contract terms are fields

A term may name a whole set. A field whose field_type is a set type the pack declares ([[reference_types]], §6.6) takes inputs.<set>, a set of that type (docs/01 §12.7):

[[contracts.fields]]
name = "market"
field_type = "Test.Reference.Market"
required = true

A rule reads the set through the term, {{contract.market.rate}}, which expands to inputs.<set>.rate. Every field of the set type is also an IMPLIED term of the contract: {{contract.renewal.share}} reads the named set's renewal.share unless the contract states renewal.share = 0.5 for itself, so one contract overrides one number and every other takes the set's. A field the named set does not state is refused where it is read (E2309), since a set declares no defaults.

There is no separate term schema. A contract type's terms are its FIELDS, declared on the master and inherited down the refinement chain exactly as an entity's are (§6.1), in types.toml:

[[contracts]]
type_id = "CRE.Contract.PermanentDebt"
refines = "Contract.Debt"
contract_name = "cre.permanent_debt"

[[contracts.fields]]        # strengthens an inherited master field
name = "amortization_months"
field_type = "integer"
required = true
unit = "months"

[[contracts.roles]]         # specializes a master role
name = "landlord"
refines = "lessor"

[[contracts.roles]]         # a master role this form of the agreement leaves unbound
name = "buyer"
unbound = true

A field's type is decimal, integer, string, boolean, date, contract or account. A boolean is a flag the agreement states as true or false (funded_at_close, balloon_at_maturity); any other value, an input or an expression included, is refused (E1389), and a rule reads it as a condition, if({{contract.funded_at_close}}, …). The last two are references to a declared contract or account by name, as a guarantee's covered and a note's principal_account, which the compiler resolves (E1376). A rule may lower a FIELD and no stream (an empty stream_name with a field_name): a structured note's claim is one, read by the waterfall steps that pay it; such a rule names no line and no category. A line may be marked allocated = true (a waterfall step pays it; no rule may emit it — a refinement may mark an inherited line allocated where its form of the agreement is paid by a structure, never the reverse) or optional = true (a name the master reserves; no rule must emit it). An allocated line carries paid_to, the role it is paid to, from its master (holder, beneficiary); a pack may state it on a line of its own, and a step paying the party bound in that role pays the line without naming it (docs/01 §10.2). A field may carry one_of = "<group>": fields sharing a group are alternatives and a contract must state at least one of them (a debt's amount is principal, commitment or draw; its rate is interest_rate or index with margin). A refinement may put a field of its own into a master's group — a rollover's renewal_rent_year and market_rent_year join the lease's rent group, a percentage-rent clause's overage_pct does too, a capex line's pct_of_revenue joins amount. The master's obligation stands (a lease states its rent); the refinement states how this form of the agreement spells it. A refinement's roles are its master's roles, specialized — it may not add a party the agreement does not have.

Every term a pack's rules read ({{contract.<key>}}, and {{periods.<key>}} through the months-to-periods conversion), every term its validations bound and every term its templates render is a field of the type — declared on the type or inherited from its master — and the loader refuses a pack where one is not. The shipped packs declare every term they use.

A field may declare the bound its value holds to. A numeric field takes min, max, exclusive_min and exclusive_max, and a string field takes values: the names and the meaning are term_number's and term_enum's (§6.8), so a bound reads the same wherever it is written. The compiler reads every term a contract or an option states against its type's effective fields and refuses a value outside the bound with E1389, so a type that declares one needs no validation beside it, and an option type, which has no contract name for a validation to attach to, is checked as a contract is. A refinement inherits the bound with the field: fee declared once on a surrender bounds a contraction and a termination. A pack does not declare a bound on a field and also a term_number or term_enum check on the same term of the same type; the bound on the field is the one place it lives. Only a literal is checked: a term that defers to an input (inputs.<name>) or states an expression is not, because its value is not known when the model compiles, exactly as term_number leaves it. The loader refuses a bound no value can meet: a numeric bound on a field that is not a number, values on one that is not a string, min with exclusive_min or max with exclusive_max, or a lower bound that reaches the upper. lookup prints each field's bound, so an author reads it before the compiler refuses the value.

A set type's fields ([[reference_types.fields]], §6.6) declare bounds the same way, and an assumption typed by the set is held to them where it is written (E2310): a literal, each point of a series, each value of a quantile and both ends of a distribution's clip. A distribution's draws and a value stated as an expression are not, as a contract's deferred term is not; a modeler who varies one bounds it with within.

parties = ["lender", "borrower"] stays the shorthand for roles inherited by the master's own word. A master also declares lines ([[contracts.lines]] name = "interest") and, where it serves one side only, side = "pays" or "receives"; a refinement inherits both, may add lines, and fixes a side the master left open. Each lowering rule names the line it emits (line = "interest" on the [[rules]] entry), and load checks that a type's rules cover its effective lines. Every shipped rule names its line. A contract template must render every required effective field and one member of each one_of group, also checked at load.

The master's fields are the schema; the lowering rule consumes them by name ({{contract.principal}}); the template renders the required ones; validations.toml bounds their values. The compiler checks a model's terms against the EFFECTIVE roster: an unknown term is refused with a near-miss hint (E1371), a missing required field or an empty group is refused (E1372), and a unit stated on a term is checked against the rule's (E5024). A rule that consumes a term the type does not declare is a pack-load error, so the three sources that once had to agree by care — rules, templates, validations — are checked against one declaration.

6.4 Lowering rules (contract → effects)

A pack MAY provide lowering rules that generate effects from terms.

Core guarantee:

  • If a pack declares a lowering rule for a contract type, the compiler MAY allow effects to be omitted in source.

Lowering rule semantics:

  • Inputs: contract instance (type, terms, subject, term date range)
  • Output: one or more streams and/or derived term expansions
  • Naming convention: generated stream names SHOULD be qualified names (dot-separated hierarchy), e.g. cre.lease.base_rent.
  • Ownership convention: owner SHOULD resolve to a qualified entity symbol.

Rule interface options:

  • Declarative lowering (recommended for v0.1)
  • Plugin function (future)

v0.1 recommended declarative structure:

{
  "lowering": [
    {
      "type_id": "Contract.Lease",
      "generates": [
        {
          "stream_name": "rent",
          "owner": "${subject}",
          "direction": "inflow",
          "currency": "",
          "schedule": {"kind": "Every", "every": "monthly", "on_rule": {"kind": "EndOfMonth"}},
          "amount_expr": {"lang": "cfdl", "src": "{{contract.base_rent}}"}
        }
      ]
    }
  ]
}

Template rules:

  • ${subject} resolves to the contract subject entity symbol. It is the only ${...} substitution, and it applies to owner_entity alone.

currency is not templated, and earlier revisions of this page were wrong to describe a ${contract.currency}: a contract has no currency to resolve — ContractStmt carries no such field, so nothing could ever have supplied one. The real mechanism is simpler. Leave a rule's currency empty and the stream inherits the model's declared currency, which is what keeps a pack usable outside the United States. Set it only when the instrument is genuinely fixed to one currency, and the model must then agree (E2107).

Contract design: decomposition and terms

A contract is a helper: the modeler follows its shape and provides the terms, and the streams emerge. These rules are what make that shape trustworthy. Each exists because its violation shipped and cost something.

One stream per economically distinct line. A contract MUST lower to one stream per line an operating or financing statement would show — interest and principal separately, gross proceeds and selling costs separately. Netting is presentation, and belongs to statements; lowering is data. A netted line cannot be un-netted downstream, so every consumer of the split — a tax line, a coverage ratio, an amortization schedule — is foreclosed at the rule. The worst form is cash the model never sees at all: a mortgage rule that emits debt service but not the loan proceeds funds nothing, and the model's levered return is wrong even while the net line reconciles.

Two shapes, chosen by the domain. An instrument lowers one contract into its components — a loan pool into interest, principal, prepayments, recoveries, servicing; a lease into rent, abatement, recoveries, TI/LC. A line item is one instanced contract per statement line — an operating expense schedule — with the vocabulary carried by the instance name (cre.opex_line.property_tax), never by an enum term restating it.

Grain by instancing; level by entity. The modeler chooses grain by how many instances they declare, and level by the entity each contract hangs on — part_of rolls it up. A rule MUST NOT take a level or scope term: that restates the entity tree in a place that can disagree with it.

Terms carry the agreement; rules carry the instrument. A term may hold an expression, so a pack SHOULD NOT pre-bake value shapes the modeler can state directly — an escalator is escalation = inputs.cpi + 0.005, not a rate/curve twin-term pair with a selector spliced into the rule. What belongs in the rule is the instrument's own mechanics: amortization arithmetic, the split of a payment into interest and principal, the schedule. What belongs in the term is what the parties agreed.

Pack streams are named [domain].[category].[line]{.[instance]}. A contract's several streams share their [domain].[category] — cre.unit. carries base_rent, abatement, recoveries and ti_lc; opco.debt. carries proceeds, interest and principal. A line-item contract puts the line in the instance slot: cre.opex.line.property_tax. Contract TYPES may use underscores (cre.lease_unit, cre.opex_line) — they are authoring surface — but the streams a rule emits may not. This applies to PACK-LOWERED streams only: a hand-written stream is the modeler's own name and no pattern is enforced on it.

Categories are the semantics; names are addresses. Every stream a rule emits MUST carry a category, and aggregation reads the category — which is why decomposition never moves a total: the components fold where the netted line folded. A statement itemizes by selecting streams; a subtotal folds by category; both stay correct as the grain changes.

Every read of an instanceable family is globbed. A rule whose stream name carries {{contract.dot_suffix}} emits per instance, and any series_sum, metric selector, or statement row reading that family MUST use <base>.* — a bare name matches only the unsuffixed instance and silently drops every sibling. A bare read is refused; # series-allow: marks the rare deliberate one.

The conventional vocabulary ships as templates. templates.toml carries the standard set — the nine expense lines a statement usually shows, with sensible defaults — as editor snippets. The modeler starts from the convention and is free to name their own instance; anything unclaimed lands on the statement's residual row rather than vanishing.

A template may join a declaration the model already has. A template creates declarations; a class in a securitization is also a step in two waterfalls that already exist, and a template that could not reach them covered four of a class's seven parts. A kind = "waterfall_step" template carries [templates.extends] naming the waterfall its steps join and the position — after = "<step>", or position = "end" — and its body is pay steps. The position is stated, never implied: a template that silently appended would make the newest class the most junior, right about half the time and wrong silently the other half. The fields take ${placeholder}s like the body. The editor inserts the body after the named step, or before the closing brace; a waterfall or step the model does not declare is an error the modeler reads, never a quiet insertion elsewhere. The pack loader refuses a step template with no position, two positions, or a body that is not steps.

[[templates]]
id = "credit.note_interest_step"
kind = "waterfall_step"
body = """
  pay ${class}_interest to party.${class}_holders for contract credit.note.${class} line interest =
        container.trust.credit_note_interest_due_${class}
"""
[templates.extends]
waterfall = "${interest_waterfall}"
after = "${after}"
[templates.defaults]
class = "class_b"
interest_waterfall = "notes.interest"
after = "class_a_interest"

The other direction is the model's: a contract's effects block adds a line the pack does not lower, and a term that line reads is admitted on the agreement though the pack type does not declare it (docs/01 §8.3). Between them, the pack's vocabulary is a shortcut and never a ceiling.

A refinement when the cash shape differs; a term when only the settlement differs. Credit's level-pay, interest-only and floating pools are three refinements because the amortization profile and the rate basis differ. An interest-only period, a balloon, how interest is met and a PIK period are TERMS on one instrument (interest_only_months, balloon_at_maturity, interest = paid | reserve | rolled_up, pik_months) because they change when the same instrument settles, not what it is. Under that rule the roster grows by instrument and the terms stay what a term sheet states.

Every rule names the line it emits. line = "interest" on a [[rules]] entry ties the stream to a line the contract's master declared. Pack load checks coverage; the category the stream carries is still the pack's classification of that line.

Scheduled cash is the contract's; discretionary cash is the waterfall's. A debt contract lowers its draws, interest and scheduled principal and carries its own balance as a lowered field. Repayment that depends on what cash is available — a sweep, proceeds of a sale — is a waterfall step allocating to the lender's party account, and the contract's balance never reads the waterfall. Where a deal repays from a named cash source, the contract takes a series as a term, as cre.construction_loan takes its draw schedule (draw = inputs.draws).

The calendar carries the cadence. A model runs at the finest cadence its instruments carry — a monthly-paying mortgage puts the model on a monthly calendar — and a source that publishes annually is read from the results' annual rollup, which is a view of the same ledger. A rule paying finer than the calendar it is on is refused (E2108): twelve payments would share one period's environment and be struck once and multiplied. A contract's day count is a year fraction the placeholder expands to, so act/act falls out of the same expansion. Statutory or workbook rounding belongs on the rule as a round_step term, never in a case's hand stream.

Parties in roles are real. A model's parties { landlord = party.acme } is validated against the type's effective roles — the master's, specialized by the pack — and carried into the IR under both the pack's word and the master role, so party.acme is a lessor to any reader that does not know CRE.

Schedule interval

Omit schedule_every and a recurring rule pays at the model's calendar cadence, which is what every shipped rule does. Set it when the instrument pays on its own rhythm regardless of the grid the model happens to use — a quarterly coupon or an annual true-up on a monthly model:

schedule_kind = "every"
schedule_every = "quarter"

Values are the schedule intervals: day, week, month, quarter, year. An unrecognized value is E5012_RULE_INVALID_INTERVAL. An interval finer than the model's calendar is E2108_SCHEDULE_FINER_THAN_CALENDAR — occurrences inside one period share that period's environment and cannot be told apart, so a pack cannot express what a model may not.

The day in the period, the roll, and the dates

A rule states the date keys a model's schedule clause takes, each spelled as the model spells it:

schedule_on_day = "{{contract.payment_day}}"   # "15", or "eom"
schedule_convention = "following"              # none, following, modified_following, preceding, modified_preceding
schedule_calendar = "us"                       # us, target, uk, weekend
schedule_except = ["2026-07"]                  # dates removed
schedule_also = ["2026-04-15"]                 # dates added

[rules.defaults]
payment_day = "15"
  • schedule_on_day is a model's on day <n> or on eom: where in its period a recurring stream falls, and, under payment terms, the billing date the lag counts from. Empty leaves the placement's answer.
  • schedule_convention and schedule_calendar roll the due date off a weekend or holiday, as a model's convention and calendar do.
  • schedule_except and schedule_also remove and add dates, as a model's except [..] and also [..] do.
  • Each key is templated, so a rule can fix a value or defer to a contract term with a default. Empty is unset.
  • A literal value the model's clause would refuse is refused at load; a templated one is checked once expanded (E5004).
  • A placement the model states in the contract's payment clause replaces the rule's day, as it replaces the rule's placement; the roll still applies. An on_date rule takes schedule_convention and schedule_calendar only, as a model's schedule on does.

Selecting rules by a term's value

A rule lowers every contract of its type unless it says otherwise. when narrows it to the contracts whose terms match, so one type carries the rows for each pattern an agreement can take without a type per pattern:

[[rules]]
id = "credit_loan_level_pay_sched_principal"
contract_name = "credit.loan"
line = "principal"
...
[rules.when]
amortization = "level_pay"

[[rules]]
id = "credit_loan_interest_only_bullet"
contract_name = "credit.loan"
line = "principal"
...
[rules.when]
amortization = ["interest_only", "bullet"]
  • [rules.when] is a table of term → value; a value may be a list, matching any of them. The comparison reads the term as STATED on the contract, or this rule's own [rules.defaults] entry for it when unstated — so a level-pay row that defaults amortization = "level_pay" matches a contract that says nothing, and the interest-only row does not.
  • when_stated = ["index"] applies the rule only when the contract writes the term; when_unstated only when it does not. This is how a floating coupon's row and a fixed coupon's row share a type: the coupon is fixed when interest_rate is stated and floating when index is.
  • when_stated_any applies the rule only when the contract writes at least one of the terms: a loan's sweep is applied where it states any covenant. It selects accounts, events, steps and valuations the same way.
  • Every term a rule selects on is a field of the type (the load check that covers a rule's placeholders covers these too).
  • Selection makes line coverage a fact about the INSTANCE: load still checks that each line has a rule somewhere, and the compiler checks that each line the type must produce has a rule whose selection admits this contract's terms — E1385_LINE_HAS_NO_RULE_FOR_TERMS names the line and the combination the pack has no row for (a floating level-pay loan).

The account a contract opens, and the rows that move it

A master may declare an ACCOUNT — Contract.Debt declares balance, owed by the borrower. A refinement OPENS it in its lowering file and MOVES it from its rows. A pack opens an account only this way, through the agreement that requires it: a reserve, an escrow or a sinking fund is a cash location fed and drawn by an agreement, so a replacement reserve belongs to the loan that requires it and an FF&E reserve to the management agreement. An account no agreement opens is the model's own, declared with account.

[[accounts]]
contract_name = "credit.loan"
name = "balance"
init = "{{contract.principal}}"

[[rules]]
id = "credit_loan_level_pay_sched_principal"
line = "principal"
account = "balance"
direction = "inflow"
amount_expr = "prev.balance * ..."

[[rules]]
id = "credit_loan_level_pay_defaults"
line = "default"
account = "balance"
direction = "writeoff"
category = ""
amount_expr = "prev.balance * ..."
  • [[accounts]] creates <subject>.<name>.<instance> on the contract's subject, the instance being the contract's id or, with none, its type word; <subject>.<name> is the fold of the subject's instances. Its side follows from the type's side: a subject that pays owes the balance, one that receives is due it, and a type opening an account must declare one. A declaration may state its own side = "owed" or "due" where the account is not the type's: a loan's debt service reserve and its trapped cash are held by the lender and due to the borrower whose loan is owed. init is the balance at the timeline's first period, expanded from the contract's terms, the calendar's {{model.periods_per_year}} and a named contract's line, account or term (below); omitted, it opens at zero and the proceeds row raises it. An energy debt service reserve funded at close opens at months of its loan's payment. when / when_stated / when_unstated select as on a rule.
  • account on a row names the account it moves, by the master's name, and moves the contract's own instance. It may instead name a term of type account, account = "{{contract.capital_account}}": the row then moves the declared account that term names, the contract wired to the model's ledger as a note's principal_account is. An inflow or outflow moves it by the account's side; accrual raises and writeoff lowers it, and those two must name one. A non-cash row carries no category.
  • Every row reads the OPENING balance as prev.<name> — prev.balance — which the compiler spells out as prev.<subject>.<name>.<instance>, the contract's own.
  • A field the contract lowers reads its own account the same way in its recurrence: a servicing agreement's advances outstanding are prev.advances less what its reimbursement account has received.
  • Load refuses a row that moves an account its type does not carry, and a non-cash row that moves nothing.
  • A machine's set <name> = <expr> where the entity carries the account is a MOVEMENT of the opening balance to that value, journaled as a move from the machine: on enter retired { set balance = 0 } writes the whole opening off, and every row reading prev.balance that period reads zero. A machine a CONTRACT binds sets nothing (docs/01 §8.6): it lists a write-off line in the state instead, as credit.loan's zero_balance runs only in prepaid and repurchased. It moves every instance of that name the entity carries. Fields that play the same name as a role are written too.

A field a pack derives on an entity type

A fact about a thing over time is a field with a recurrence on the thing that owns it, and a pack may state that recurrence once, for a type, in the lowering file beside [[accounts]]:

[[fields]]
type_id = "Energy.Asset.GenerationFacility"
name = "degradation_factor"
when_stated = ["degradation_rate"]
init = "1.0"
next = "prev * (1 - {{entity.degradation_rate}} / {{model.periods_per_year}})"
[fields.defaults]
degradation_rate = "0.005"
  • The field is derived on every entity whose type is, or refines, type_id and is not one of except_types, and that the declaration SELECTS: when_stated names literals the entity must state, when_unstated literals it must not, and [fields.when] selects by a literal's value exactly as [rules.when] selects a rule by a term's — reading the literal as stated, or this declaration's default for it. One field may therefore have several recurrences, one per shape, and the modeler chooses by name; two declarations with the same selection are refused at load, and two that both admit one entity are E5021.
  • {{entity.<field>}} splices a literal the entity states — a number in parentheses, a string quoted — or a default the declaration supplies. A default may itself read a literal ({{entity.<other>}}), so the fact that usually equals another need not be restated. A literal the recurrence needs and the entity does not state is E5006, naming it. The cadence placeholders (model.periods_per_year) apply.
  • The field publishes as the entity's own, asset.<name>.<field>, readable by any line, term, assumption or metric — and by a contract term such as occupancy = asset.north.occupancy, which is how absorption reaches the rent, the parking and the variable expense without being restated. The term is a read: an entity outside the selection writes its own field of the same name and the lines read it the same way.
  • A model may not declare a field of the same name on an entity the pack derives it on (E5021): one statement of a fact. Load refuses a declaration on a type the ontology lacks, an exception the ontology lacks, or a read of a literal the type does not declare.

Valuations: figures at a date, never cash

A refinement may publish a VALUATION: the asset's value at the contract's date, as figures rather than as a stream. Declared in the lowering file beside [[accounts]]:

[[valuations]]
contract_name = "cre.exit"
reach_years = "1"
[valuations.when]
basis = "forward_noi"
[[valuations.figures]]
name = "income"
expr = "series_sum(\"entity.{{contract.subject}}.domain.cre.noi\", {{time.term_start_period}} + 1, {{time.term_start_period}} + {{model.periods_per_year}})"
[[valuations.figures]]
name = "gross_value"
expr = "metric.{{contract.name}}.income / {{contract.cap_rate}}"
  • Each figure is a metric named <contract>.<figure>, published as metric.<contract>.<figure> and folding the finished projection, tail included. It is expanded from the contract's terms as a rule's amount is, with {{time.term_start_period}} and {{time.term_end_period}} — the 0-based period the contract's dates fall in — beside the cadence placeholders.

  • A figure may read the figures declared above it as metric.{{contract.name}}.<figure>, so gross value, each cost of sale and net value are separate, any may be asserted, and a moved term shows in the figure it moved. A model's own metric may read them too.

  • reach_years is how far past the contract's date the figures fold. A timeline whose project tail does not reach that far is refused (E5042), naming the periods missing: a valuation is never folded short. A valuation folds whole months, quarters or years, so a daily or weekly calendar is refused as well.

  • A figure may be published every period. per_period = true on a figure asks the same appraisal at each period of the cash horizon, with time.t the valuation date, and publishes the series metric.<contract>.<figure> instead of a scalar:

    [[valuations.figures]]
    name = "gross_value"
    per_period = true
    expr = "series_sum(\"domain.cre.noi\", time.t + 1, time.t + {{model.periods_per_year}}) / {{contract.cap_rate}}"

    Opt in per figure, and only for a figure that is valid at any date: an appraisal (forward income, gross value) is; a disposal's costs and net proceeds are not, and stay at the contract's date. The projection must reach the last period of the cash horizon plus reach_years (E5042). A metric or a statement reads the series by name; a later per-period figure of the same valuation reads an earlier one at the same period. Nothing causal reads it: a stream paying it is refused (E2111), since a stream pays a valuation at its date.

  • A figure may be published under its SUBJECT's name, where it is a fact about the thing rather than the agreement: publish_as = "{{contract.subject}}.value" publishes metric.asset.north.value. Two contracts on one subject publishing it on the same terms are one figure; on different terms each speaks up to its own contract's date and the last one after it — a partial sale and a later sale of the rest value the asset on the terms of the next sale.

  • A valuation selects the contracts it applies to as a rule does, with [valuations.when], when_stated and when_unstated: cre.exit carries one valuation per basis, each with its own reach_years.

  • A figure may read a pack subtotal FOR ONE ENTITY, series_sum("entity.{{contract.subject}}.domain.cre.noi", …): the subtotal folded over the entity's streams and its parts' (docs/03 §4), so a building's exit values its own NOI in a model that holds several.

  • A valuation's figures may name their own contract's streams with the name keys a rule has: {{contract.suffix}}, {{contract.dot_suffix}}, {{contract.suffix_ident}} and {{contract.subject}}, and an account it opens as {{contract.account.<name>}}, the account's full name: a loan's debt yield folds account.{{contract.account.balance}}. It reads the party the contract binds in a role, irr({{contract.party.holder}}), and a named contract's terms, lines and accounts as a rule does.

  • A valuation is figures; the SALE is a reversion stream paying, on its date, the valuation's net figure or a price the case states. Paying the valuation is the DCF convention. A stream that pays a figure is deferred behind the fold: a sale paying its valuation, a loan whose principal defaults to its sizing figure (docs/01 §9).

A contract type's named events

A pack states an event its contract type carries: the named event a model writes, for what a machine's guard cannot say, such as a covenant tested only on its test dates. Declared in the lowering file beside [[rules]]:

[[events]]
contract_name = "credit.loan"
id = "dscr_test"
schedule_kind = "every"
schedule_every = "quarter"
schedule_from = "{{contract.term_start}}"
schedule_to = "{{contract.term_end}}"
when = "{{contract.subject}}.dscr < {{contract.dscr_trigger}}"

[[events.actions]]
field = "status"
value = '"cash_trap"'
  • Each contract of the type carries one event <contract>.<id>, emitted at compile beside the model's own and checked as a model's is: a status write takes an edge the subject's machine declares.
  • The schedule takes the keys a rule's does (schedule_kind of every or on_date, schedule_every, schedule_from, schedule_to, schedule_on_day, schedule_convention, schedule_calendar, schedule_except, schedule_also). With no schedule_kind, the event fires on its condition's rising edge. An event states a schedule, a when, or both.
  • Every slot is templated from the contract's terms, with [events.defaults]; {{contract.subject}} is the entity the contract is written on.
  • subject_types limits the event to contracts whose subject is, or refines, one of the listed entity types: cre.lease_unit vacates its unit when a termination ends it early, and a unit lease on a building has no unit to vacate.
  • when_stated, when_unstated and when_stated_any select the contracts that carry the event, as on a rule: cre.spec_lease opens a unit's re-let with an event only where it states no area, since on a share of a building there is no unit to move; a loan is tested for its covenants only where it states one.
  • [[events.actions]] is set on that entity: a field, or status. An action may name another node with entity, templated: entity = "contract.{{contract.name}}" moves the agreement's own machine (docs/01 §8), as a covenant test moves a loan into breach. A field may be named per contract, loan_cured_at{{contract.suffix_ident}}. The event reads the contract's accounts as {{contract.account.<name>}}. An option's exercise is the model's.
  • A model's event of the same name replaces the pack's.
  • The loader refuses an event with neither a schedule nor a when, with no action, with a malformed literal schedule value, or reading a term its type does not declare.

What an election does

A contract type that names no contract_name binds no lowering rule, so it is an election: a model writes it only as an option over the contract it is exercised on, never as a contract. A servicer's buyout of delinquent loans (Credit.Contract.Buyout) is such a type.

A pack states what exercising one of its option types does, once, in the lowering file beside [[events]] (docs/01 §14):

[[options]]
option_type = "CRE.Option.Termination"
payoff = "{{contract.fee}}"
category = "operating.revenue.other"
[options.defaults]
fee = "0"
notice_months = "0"
[[options.actions]]
kind = "set"
entity = "{{agreement.subject}}"
field = "lease_until{{agreement.suffix_ident}}"
value = "time.t + {{contract.notice_months}} * time.ppy / 12 + 0.5"
  • The rule governs the option type it names and every type refining it; the loader refuses one naming a type that is not an option, or that neither pays nor acts.
  • A model option of the type that states no payoff takes the pack's, with its category; one that states no actions takes the pack's. What the model states replaces the pack's, as a model event replaces a pack event.
  • {{contract.<term>}} reads the option's term, then the agreement's, then [options.defaults]; a term none states is E1372. The agreement the option is written on (on contract) is {{agreement.name}}, {{agreement.subject}}, {{agreement.suffix}}, {{agreement.dot_suffix}} and {{agreement.suffix_ident}}, so an action names the agreement's fields and streams, and {{agreement.account.<name>}} an account it opens: a loan's extension fee is struck on its balance.
  • An action is set (an entity, a field or status, a value), activate or deactivate (a stream), the event action vocabulary.

The step a contract contributes to a waterfall

A refinement with an ALLOCATED line, paid by a priority of payments rather than lowered, may declare the step that pays it. Declared in the lowering file beside [[accounts]]:

[[steps]]
contract_name = "credit.note"
line = "interest"
name = "{{contract.suffix}}_interest"
account = "{{contract.interest_account}}"
amount = "{{contract.subject}}.credit_note_interest_due{{contract.suffix_ident}}"
  • A model's pay for contract <name|"selector"> line <role> (docs/01 §10.2) becomes one step per matching contract and step rule, in seniority order, then the order the pack states its rules for the line, then declaration order — so one line may be a whole priority of distributions, as an equity interest's preferred return, promote and residual are: name, amount and account expanded from the contract's terms with {{contract.name}}, {{contract.suffix}}, {{contract.dot_suffix}}, {{contract.suffix_ident}} and {{contract.subject}}. The step names the contract and the line.
  • The step pays the account account names. Where it is absent, or reads a term the contract does not state, the step pays the party the contract binds in the line's paid_to role.
  • amount is an ordinary step amount (docs/01 §10.3): it may read remaining, and the engine pays min(max(0, amount), remaining).
  • pari_passu = true makes the steps one rule contributes for one seniority share what is left pro rata by their amounts: step i pays min(a_i, remaining × a_i / (a_i + … + a_n)), in full where the pot covers them and its share where it does not. Partners ranking together are paid their preferred return this way; a rule without it pays in declaration order, as note classes of different seniority are.
  • A step reads a named contract's terms, lines and accounts as a rule does ({{contract.<term>.term.<name>}}): a promote reads the interest whose hurdles it measures.
  • [steps.defaults] supplies a term the contract leaves unstated, and [steps.when], when_stated, when_unstated and when_stated_any select as on a rule: a loan's trap line holds the cash in its lender's trap account on a cash trap and pays it to its swept account on a sweep, one step each, contributed only by a loan that states a covenant. Two rules of one name may divide a step by what the contract states: a promote with no catch-up and no hurdle is its residual share of what is left, and the tiered form applies only where a tier is stated. The step names an account the contract opens as {{contract.account.<name>}}.
  • The loader refuses a rule for a type the pack does not declare, for a line the type does not declare allocated, or whose templates read a term the type does not declare.

Currency

Omit currency from a lowering rule. An empty value inherits the model's declared currency, which is what keeps a pack usable outside the United States — a PPA in Rajasthan is not a USD contract, and a lease in Frankfurt is not either. The model is where a currency belongs, and it defaults to USD when a model declares none, so nothing is lost by leaving it out here.

Set it only when the instrument is genuinely denominated in a fixed currency regardless of the model around it — a USD-denominated eurobond, say. A rule that pins a currency the model does not declare is rejected with E2107_STREAM_CURRENCY_MISMATCH, because cash flows are summed period by period and the two would otherwise be added as though the units matched.

Compiler behavior:

  • Lowering runs after core validation.
  • Generated streams MUST carry provenance notes referencing the contract.
  • Generated streams SHOULD use deterministic dotted naming to preserve ontology/data-source mapping stability.
  • If lowering fails, emit E500x and fail compilation.

6.5 Term payloads (current host behavior)

Contract terms { ... } values are captured as a lightweight map and exposed to pack lowering logic.

Current contract for packs:

  • Terms are key/value pairs with string payloads plus source span.
  • Packs are responsible for explicit parsing/coercion (e.g. Int/Decimal/Date).
  • Packs should not rely on implicit casts; invalid values must emit diagnostics.
  • If term-level spans are unavailable for a rule, use contract span consistently.

6.6 References (market observables)

A pack's ontology declares its references in types.toml — [[references]] entries with a reference_id, a kind (rate_curve, index, price_curve), an optional unit and a description. They are vocabulary: an observable a model names in an assumption's type slot (assume prices : energy.power_price = quantile { … }, docs/01 §12.6), which gives the value, series or quantile its unit and enters it in the IR's required_refs. kind is documentation and is checked against nothing.

Set types. A pack may also declare [[reference_types]] entries: the roster a set-shaped assume is held to when a model types one with it (docs/01 §12.7). Each has a type_id (CRE.Reference.MarketLeasing), a description and [[reference_types.fields]] with a name, a field_type (decimal, integer, string), an optional unit and a description. A dotted name nests a subset — new.ti_psf is the field ti_psf of the subset new — so the model writes new = { ti_psf = 60 "USD/sf" … } and reads inputs.<set>.new.ti_psf. The roster declares no defaults: a set states the numbers its underwriting has an opinion about, and a read of one it leaves out is refused rather than given a number the model never stated. The type is optional on the statement: an untyped set is accepted in a model with no pack.

Earlier revisions of this section described observables.json and refs.json registries and an obs.rate(...) accessor family. No loader has ever read such files and no such accessors exist; what arrives from outside is an assumption the run supplies, by full name, through a parameter or the inputs file (docs/01 §12.1).

6.7 Expression functions

The expression function vocabulary is fixed and built into the engine (cfdl-calc) — pmt, year_frac, cpr_to_smm, curve_value, and so on (see the Expression Environment). Packs do not define their own expression functions in v0.1.

6.8 Pack validations

Packs can add domain-specific validations, e.g.:

  • Lease must have start/end
  • Construction loan must have draw period
  • Exit cap must be within bounds

Validation must:

  • Produce diagnostics with stable codes
  • Include file/span when possible
  • Never crash

Validations are declared as data in packs/<pack>/validations.toml and evaluated by the compiler; they are not implemented in the engine. Each rule names the contract it applies to — contract for one, contracts for a list, exactly one of the two — the check, a stable diagnostic code, a message, and an optional severity (error by default). term names the term under test; terms lists them for any_term_present; values lists what term_enum accepts; left/op/right are term_compare's operands. Available checks: term_present, any_term_present, requires_any (where term is stated, one of terms must be: a loan locked out of prepayment states its make-whole rate; with values, where term states one of them: a note that defers its interest states the account the interest accrues to. It reads the terms a contract states, never a type's default, so a term the contract omits never fires the check, whatever it defaults to), term_at_horizon_end (where term states one of values, the contract is dated the cash horizon's last period: a sale on forward income), excludes_any (where term is stated, none of terms may be: a sale stated as a ledger of closings states no pace; with values, where term states one of them, read as requires_any reads it: an equity commitment funded at_start states no funds_after), terms_absent (none of terms may be stated: a term the type inherits from its master and no rule of the pack reads, since a refinement cannot drop a master's term), terms_mutually_exclusive, term_number (integer or decimal, with min/max/exclusive_min/exclusive_max, and when/on_invalid to control absent and unparseable values), term_range_within_timeline, term_enum, term_compare, and two that read the contract's SUBJECT: subject_type, which refuses a subject that is, or refines, one of types (with a term, only where the contract states it — a unit sale's pace on an individually modeled unit), and subject_field_enum, which requires a literal the subject states, field, to be one of values, reading default where the subject leaves it out (a unit sale on a building not held for sale). The set is closed — no expressions, recursion, or message interpolation — so evaluating a pack's validations is bounded work that cannot crash or hang the compiler.

The file declares its pack's reserved code prefix, which the loader enforces:

  • E6xxx_* for CRE
  • E7xxx_* for OpCo
  • E8xxx_* for Energy
  • E9xxx_* for Credit

Terms that a lowering template requires are checked generically by E5006_MISSING_CONTRACT_TERM; validations express the domain constraints on top of that (bounds, enumerations, and relationships between terms).

6.9 Documentation metadata, clues and named deal shapes

Packs SHOULD provide docs for:

  • types and fields
  • contract templates
  • output definitions
  • examples

Editors may use this for hover hints and snippet insertion.

Clues. An entity or contract type MAY carry clues, a list of the tells in a specification that say a deal has one: what an author reads in a term sheet, a rent roll or an offering document that means the type applies. A template MAY carry them too. The shipped packs give every concrete type clues, and the tooling returns them beside the type. The language's own constructs (a waterfall, an account, a slice) carry clues in the terminology register, which are pack-neutral.

[[contracts]]
type_id = "Credit.Contract.Note"
clues = ["a class of notes has a face amount, a coupon, a rank and is paid from a priority of payments"]

Named deal shapes. A template with kind = "shape" is a whole deal of a kind: every entity, agreement, account and waterfall a deal of that kind holds, wired together. Its body is declarations, and its parts are other templates of the pack rendered after the body:

[[templates]]
id = "credit.securitization"
kind = "shape"
clues = ["an offering memorandum names classes of notes with a rank"]
body = """ ...the trust, the pool, the collection accounts, and the two
waterfalls, each `pay for contract "credit.note.*" line <line>`... """
[[templates.parts]]
template = "credit.note_class"
each = "classes"
as = "class"
[templates.defaults]
periods = "60"
classes = "a, b, c"

[[templates]]
id = "credit.note_class"
kind = "part"
body = """ ...the holders, their accounts and `contract credit.note ${class}`
with `seniority = ${index}`... """
part keymeaning
templatethe template the part renders, by id: a contract, a part, or another shape
eacha list parameter of the shape, comma-separated; the part renders once per item
asthe parameter each item binds; ${index} is its position from 1
whenthe shape's parameters the part requires, as [rules.when] selects a rule
paramsparameters the part's template receives, themselves rendered with the shape's
  • Choices. [templates.choices] lists the values a parameter admits. A CRE strategy takes property and includes that property class's operating shape as a part. Its default is one of the values.
  • Defaults may name parameters. A default may name another parameter (ops_start = "${term_start}"), so it follows what the caller supplies.
  • Grid defaults. periods, start and calendar among a shape's defaults are the grid a deal of its kind runs on. The tool fills ${term_start}, ${term_end} and ${start_plus_<n>}, the date n periods after the first, so a shape dates an expiry or a sale against the grid.
  • kind = "part". A part is a fragment only a shape renders. It never compiles alone, and a part no shape names is refused.
  • Load checks. The loader refuses a part naming a template the pack lacks, an each or when naming a parameter the shape does not default, an each with no as, a choice whose default it does not admit, and parts or choices on a template that is not a shape. A waterfall step is never a part: a shape's waterfall collects the steps contracts contribute (docs/01 §10.2).

A shape states its numbers as the pack's illustrative values. A tool that renders it lifts every number a run may supply into an assumption stated at the model's top with that value (docs/01 §12.1), so the rendered model runs as it is and a run's inputs replace any of them. The numbers the compiler reads stay literal: a term a rule converts to periods or selects on by value, a term a validation enumerates, compares or ranges, seniority, and a boolean. A term a rule selects on only by whether it is stated (when_stated, when_unstated) is lifted like any other: the compiler needs it present, not its value.

6.10 Reporting: categories, subtotals and statements

A pack describes how its domain reports cash — what each line item is, what subtotals and ratios matter, and how a statement is laid out. Declared in statements.toml, registered in [entrypoints]:

[entrypoints]
statements = "statements.toml"

What you get

  • Per-period line items, grouped the way the domain groups them.
  • Subtotals and ratios computed every period, not just over the life of the deal — a lender tests coverage each year, and a lifetime ratio of 1.4 can contain a year at 0.9.
  • Several statements from one model. A pack can publish a monthly pro forma and an annual summary of the same cash, and more than one layout of it: a remittance report and a statement of operations read the same pool differently.
  • A reconciliation on every statement, so the bottom line is checked against the model's own cash rather than assumed to match.

Step 1 — declare the vocabulary

A category says what a stream is, economically. It is a dotted path whose first segment is one of operating, investing, financing — the sections of a statement of cash flows (ASC 230-10-45 / IAS 7.10). The pack declares the leaves it uses in pack.toml:

categories = [
  "operating.revenue.base_rent",
  "operating.expense.opex",
  "financing.debt.service",
]

The list is a recommendation, not a gate. The three roots are what the language validates, with or without a pack, so a model may name a leaf the pack never enumerated — a departmental operating expense, an acquisition basis — and it folds exactly as a listed one does. A well-rooted category the pack does not list raises W5023 naming the near match, which is what keeps two models in one pack from spelling the same idea two ways.

A pack cannot enumerate every leaf a deal needs, because the leaf is not knowable by a pack that shipped before the deal. What the list carries is the domain's conventional spelling.

Step 2 — classify at the point of emission

The lowering rule that creates a stream says what it is:

[[rules]]
id = "cre_lease_base_rent"
category = "operating.revenue.base_rent"

A hand-written stream can declare one directly:

stream cre.unit.base_rent.a on entity asset.tower inflow currency USD {
  schedule every month from 2026-01 to 2026-12
  category operating.revenue.base_rent
  amount = 10000
}

Because the rule that emits a stream is the thing that classifies it, a stream is reported as a line and counted in its subtotal — never one without the other.

Step 3 — declare subtotals

Each is a per-period series published as domain.<pack>.<name>.

[[subtotals]]
id = "domain.cre.noi"
kind = "money"
op = "sum"
categories = ["operating.*"]

[[subtotals]]
id = "domain.cre.debt_service"
kind = "money"
op = "negated_sum"
categories = ["financing.debt.service", "financing.debt.mortgage_insurance"]

[[subtotals]]
id = "domain.cre.dscr"
kind = "number"
op = "ratio"
numerator = "domain.cre.noi"
denominator = "domain.cre.debt_service"
fieldmeaning
idoutput key; must start with domain.
kindmoney or number
opsum, negated_sum, ratio
categoriesselectors to aggregate; operating.* matches the prefix and its children
streamsstream-name selectors, for what a category cannot express
subtotalsother subtotals to add, declared above this one
numerator / denominatorfor op = "ratio"
formulaa human-readable note; not evaluated

Declare in dependency order — a subtotal may reference only ones above it.

negated_sum exists because cash is stored signed. An expense is negative, and a line a reader expects positive — debt service, a servicing fee — flips once here rather than at every consumer.

A ratio is recomputed from its inputs at whatever grain it is reported at. An annual coverage ratio is annual NOI over annual debt service; it is never the average of twelve monthly ratios, which would be a different and wrong number. Where the denominator is zero the value is null, not zero — a period with no debt service has no coverage ratio.

Step 4 — lay out the statement

[[statements]]
id = "operating"
label = "Operating statement"
default = true

[[statements.rows]]
kind = "line"
label = "Base rental revenue"
depth = 1
categories = ["operating.revenue.base_rent"]

[[statements.rows]]
kind = "line"
label = "Less: vacancy"
depth = 1
categories = ["operating.deduction.vacancy"]
display = "positive"

[[statements.rows]]
kind = "subtotal"
label = "Net operating income"
depth = 0
subtotal = "domain.cre.noi"

[[statements.rows]]
kind = "ratio"
label = "Debt service coverage"
depth = 0
subtotal = "domain.cre.dscr"

Row kinds are line, subtotal, ratio, spacer and residual. depth indents. display = "positive" shows a stored negative as a positive number in a "less:" row — the published value stays signed, so anything consuming the data still adds up correctly while a rendered statement reads the way a practitioner expects.

A line row draws what a model's row can: categories, streams, types and lines (a line by role on a contract type), a declared slice, an entity and its descendants, or a published series, which presents a fold and claims no cash. A series with a * sums every series it matches, so a memo row shows a figure each contract instance publishes: series = "metric.cre.affordable_covenant.*.affordability_gap".

A statement folds cash. A stream whose direction is accrual or writeoff moves an account and no cash total, so it is in no row and no residual.

A table over the contracts rather than a fold of the cash — a rent roll, a lease expiration schedule, a debt schedule, distributions by partner — is the renderer's, from graph.contracts and the published series, not a statement (docs/01 §15.5). A debt schedule is the loan's balance account and every stream whose moves names it, whichever contract made it: the sale's payoff moves the loan it retires.

A statement generated from a hierarchy, and slices

A statement may state a structure instead of rows, as a model's does (docs/01 §15): entity or category, with depth, a filtering slice and metrics. Its rows follow from the tree, so it partitions the cash by construction. A statement states a structure or rows, never both.

[[slices]]
id = "note_principal"
types = ["Contract.Security"]
lines = ["principal"]

[[statements]]
id = "by_property"
label = "Cash by property"
structure = "entity"
depth = 2

[[slices]] takes every clause of a model's slice: entities, types, lines, categories, streams, the three except_ lists and a window = ["<from>", "<to>"]. A slice must select something.

A pack's statements and slices are declarations. The compiler emits each as the statement or slice a model would write, ahead of the model's own, into the IR's views. They are checked as a model's are, their type and line clauses are expanded against the model's streams, and the one evaluator renders them, for every model that uses the pack, whether or not a run names it. A model's statement or slice of the same name replaces the pack's. A ratio row divides the two subtotals its subtotal declares, with its own published subtotal as the fallback.

How the same cash reads at another grain

A pack declares its statements at the model's grain and ships no annual copy of them. A reader regroups a statement's columns by calendar quarter or year: a flow row sums its periods, and a ratio row publishes its numerator and denominator beside its values, so a regrouped ratio is the group's numerator over its denominator (annual NOI over annual debt service), never a mean or one period's value. The playground's statement view regroups this way.

A statement may still state a grain: a model's own covenant statement, tested annually, publishes at that grain with its own column labels, and its ratio rows divide the re-bucketed operands in the same way.

How to offer more than one view of a domain

Declare several statements over the same categories. The same pool can present as a remittance report — principal split scheduled and unscheduled, because that is what a prepayment speed acts on — and as a statement of operations reporting total and net investment income. See packs/credit/statements.toml.

Every category must appear exactly once

In each statement that states its rows, every category the pack declares must appear in exactly one line row. A statement filtered by a slice that selects categories owes only those: sources and uses covers the capital, and the operations belong to another statement. This is checked when the pack loads, before any model runs.

A category in no row is cash the statement never shows, so the bottom line is short. A category in two rows is cash counted twice — and a statement that is wrong by double counting looks entirely plausible. The check is what lets a pack offer several layouts safely: each is provably complete.

At run time a residual row catches any stream carrying no category at all, and reconciliation.residual — the statement's bottom line against the model's cash — is published on every statement whether it is zero or not.

7) Compiler ↔ Pack API (programmatic)

7.1 Pack loader interface

The compiler should expose a minimal interface:

  • load_pack(name, version) -> Pack
  • Pack.type_registry() -> TypeRegistry
  • Pack.aliases() -> AliasRegistry
  • Pack.contract_schema(type_id) -> Option<ContractSchema>
  • Pack.lowering_rule(type_id) -> Option<LoweringRule>
  • Pack.observable_registry() -> Option<ObservableRegistry>
  • Pack.ref_registry() -> Option<RefRegistry>
  • Pack.output_spec() -> Option<OutputSpec>

7.2 Error behavior

  • Pack not found: E4004_MISSING_PACK
  • Pack manifest invalid: E4004_MISSING_PACK with details
  • Unsupported IR version: E4004_MISSING_PACK with details

7.3 CLI

Recommended CLI behaviors:

  • cfdl pack list --path packs/
  • cfdl pack validate --path packs/
  • cfdl compile <model> --packs packs/
  • cfdl run <ir> --packs packs/ --config run.json

8) Determinism and provenance with packs

8.1 Determinism

Pack identity MUST participate in determinism:

  • The id generation seed includes <name>@<version> when a pack is active, because a different pack lowers a contract differently and so produces a genuinely different object.
  • The seed MUST NOT include the compiler version. An id identifies a thing in a model, not the build that emitted it; including the version rewrote every id on every release, churning goldens and making a downstream store treat the same entity as new after an upgrade. The compiler version belongs in provenance, and stays there.

8.2 Provenance

The compiler SHOULD record pack info in top-level provenance notes.


9) Testing packs

Packs must be tested via golden fixtures.

Recommended test types:

  • Pack load tests
  • Alias resolution tests
  • Template expansion tests
  • Lowering tests (contract → streams)
  • End-to-end example fixtures (compile + run results)

For each pack, include:

  • Models that exercise the pack
  • Gold IR and results

10) Future-proofing (non-normative)

v0.2+ may add:

  • multi-pack layering (base + overlays)
  • signed pack artifacts
  • executable lowering plugins (WASM)
  • richer ontology reasoning
  • sensitivity analysis definitions in output spec
  • custom engine computation plugins

This v0.1 interface is designed to evolve without breaking core models.


Parameterized lowering rules (templates)

Lowering-rule fields amount_expr, schedule_from, schedule_to, stream_name, schedule_net_days, schedule_net_months, schedule_every, schedule_on_day, schedule_convention, schedule_calendar, and each entry of schedule_except and schedule_also may contain {{...}} placeholders, resolved at compile time:

  1. {{contract.term_start}} / {{contract.term_end}} — the contract's term A..B range (normalized dates).
  2. {{contract.<key>}} — the contract's terms { <key> = <value> } entry (dotted keys like lease_up.months are supported).
  3. Rule defaults — a per-rule [rules.defaults] table supplies fallback values when the contract does not declare the term. A default may read the agreement, expanded once against its name keys and stated terms: area = "{{contract.subject}}.rentable_area" defaults a lease's premises to its unit's area, and {{contract.subject_parent}} is the entity the subject is part of (the subject itself where it is part of nothing), the building a unit's lease recovers the expenses of. A default never reads another default.
  4. A read through a contract-typed term, which names another contract the model declares: {{contract.<term>.line.<role>}} is the stream that contract lowers for that line (several are joined as | alternatives), for a selector; {{contract.<term>.account.<name>}} is the account it opens, <subject>.<name>.<instance>, for a prev. read or a rule's account key; {{contract.<term>.term.<name>}} is one of its terms, as it states it or as a rule of its type defaults it, so a loan's advance reads its servicing agreement's advancing; .term.name, .term.suffix_ident, .term.term_start and .term.term_end are its full name, its instance as a field suffix and its term's dates, so a sale's payoff is named for the loan it repays and a mezzanine repaid by a sale is repaid at the sale's date. A term another type's rules read this way counts as read for the load check. A valuation figure needs nothing new: it is metric.{{contract.<term>}}.<figure>.
# A take-out: its payoff moves the loan `pays_off` names, and its fee reads
# that loan's interest in the period before.
[[rules]]
id = "takeout_payoff"
contract_name = "test.takeout"
line = "payoff"
account = "{{contract.pays_off.account.balance}}"
amount_expr = "prev.{{contract.pays_off.account.balance}}"
# ...

[[rules]]
id = "takeout_fee"
contract_name = "test.takeout"
line = "fee"
amount_expr = "-{{contract.fee_rate}} * series_sum(\"{{contract.pays_off.line.interest}}\", time.t - 1, time.t - 1)"
# ...

The loader refuses a read through a term that is not contract-typed on the rule's type. At compile, a named contract that lowers no such line or opens no such account is E1376, naming what it has. Reads follow the usual order: a same-period line read is a series dependency, and an account read is prev..

A placeholder with no contract value and no default is a compile error (E5006_MISSING_CONTRACT_TERM), one diagnostic per missing key.

Where a one-shot flow sits in its period

schedule_kind = "on_date" settles on the stated date, which discounts from the period's open. Set schedule_placement = "end" for a disposal: a reversion is taken at the end of the holding period and discounts the full n periods. The distinction is by kind, not by being one-shot — acquisitions, draws, dated leasing costs and tax credits all want the default.

Mid-period discounting

schedule_placement = "mid" puts a rule's cash halfway through the period that earned it rather than at its end — the mid-period (mid-year) convention that project finance and banker DCFs use, on the reasoning that a period's cash arrives throughout it rather than on the last day.

It is a convention rather than a date, so it is half a period on every calendar. It works on every and on on_date rules alike. It is one of three positions, and a rule states at most one, as schedule_placement = "start" | "mid" | "end"; a rule that also states schedule_on_day falls on that day.

Use it for operating flows. Do not use it for a price: a disposal, a terminal value or an acquisition is struck at a point in time and discounts whole, which is what schedule_placement is for.

A rule may declare a field

A rule that compounds a rate which MOVES cannot use pow(1 + g, t) — that applies one period's rate as though it had held from the start, which is exact while the rate is flat and wrong the moment it varies. Three optional keys let the rule declare a recurrence instead:

amount_expr = "{{contract.amount}} * field.opco_revenue_growth{{contract.suffix_ident}}"
field_name  = "opco_revenue_growth{{contract.suffix_ident}}"
field_init  = "1"
field_next  = "prev * pow(1 + curve_value(\"{{contract.growth_curve}}\", time.date), 1 / {{model.periods_per_year}})"

The field is attached to the contract's subject entity, because that is what the value is a fact about. field.<name> is the rule's own placeholder for it: a rule cannot know which entity it will be attached to, so it names the field it declares and lowering rewrites that to the entity path a model would write.

A field a contract brings inherits the contract's own clock:

field_every = "{{contract.payment_frequency}}"
field_from  = "{{contract.term_start}}"
field_to    = "{{contract.term_end}}"

field_every is the recurrence's cadence — day, week, month, quarter, year. Empty means every model period. Set it whenever the recurrence belongs to the instrument's rhythm rather than the book's. A pool carried on a daily calendar but paying monthly must compound twelve times a year, not three hundred and sixty-five, and {{time.elapsed_periods}} counts the rule's payment periods — so a rule whose schedule_every is templated almost certainly wants field_every templated the same way.

The field steps on its accrual periods and holds between ticks and outside field_from/field_to. It does not fall to zero: that is what separates a cadence from active when, which a field does not have. An interval finer than the model calendar is E2108_SCHEDULE_FINER_THAN_CALENDAR, the same rule a stream obeys.

All templated. field_name must expand to a single identifier — field.<name> resolves one segment, so {{contract.dot_suffix}} would produce an unreachable path; use {{contract.suffix_ident}}.

A rule may read BOTH ENDS of the field it declares. field.<name> is the value at this period and prev.field.<name> is the value at the previous close, which is what an average-balance accrual needs:

amount_expr = "(prev.field.loan_balance + field.loan_balance) / 2 * {{contract.rate}}"

Both spellings lower through the entity root, since the bare family alias covers the four declared families only and a rule may sit on any entity. A stream reading prev must not start in the model's FIRST period — there is no close before it (E1129).

What a rule may NOT do is read a stream from field_next: a recurrence sees prev, other fields' previous values, time.*, inputs.*, cfg, obs and curves, and no series at all. That absence is what makes cycles impossible by construction, so a balance cannot be defined as "last period plus this period's draw stream". Derive it instead — a cumulative field over the schedule, with the period's split computed from it. One field carries the cumulative draw, and equity draw, debt draw and opening balance all fall out of min/max over it.

Fields are deduplicated by name across contracts. Identical definitions collapse, which is what several contracts sharing one curve should do; differing ones are E5021_DUPLICATE_LOWERED_FIELD rather than one silently winning.

The construct itself is language-level and needs no pack: see §3.1 of docs/03_expression_environment.md.

Cadence placeholders

A rule must not assume how long a period is. These placeholders carry that, and are resolved before contract terms — declaring a term under one of the reserved prefixes model., time., periods. or whole_periods. is E5016_RESERVED_TERM_PREFIX, because the term would never be read.

PlaceholderExpands to
{{model.periods_per_year}}365 / 52 / 12 / 4 / 1
{{model.calendar}}the rule's effective frequency name
{{model.accrual_divisor}}what a nominal annual rate divides by
{{model.amortization_divisor}}what a level payment is struck from
{{model.accrual_divisor_back.<term>}}the accrual divisor of the rule-period <term> payment periods and one before this one, for a lagged recurrence: a balance recovery_lag_months back accrued at that period's day count
{{periods.<term>}}<term> months as periods, fractional allowed
{{whole_periods.<term>}}the same, but must be integral
{{time.elapsed_periods}}whole periods since term_start
{{time.elapsed_years}}whole years since term_start
{{time.periods_to_term_end}}whole periods from now to term_end
{{contract.dot_suffix}}the contract instance's suffix, .core
{{contract.suffix_ident}}the same without the dot, _core — for state names
{{contract.subject}}the contract's subject, asset.north — for a stream another contract selects by development
{{placement_share.<line>}}the share of its period the contract's <line> is outstanding: 1 at the start, 0.5 mid, 0 at the end — the contract's payment <line> placement, else the line's rule's, else the form's default

Periods-per-year comes from the rule's payment interval, not the model's calendar. A rule that declares schedule_every accrues on that rhythm, so a monthly-paying loan carried on a daily book divides by 12, not 365. This is why the value is resolved here rather than exposed at run time: the expression environment sees the calendar and knows nothing of the schedule. Hand-written models get time.ppy instead, which is the calendar-based equivalent.

schedule_every is itself templated, so a contract can declare its own rhythm (payment_frequency = "month") and one rule can serve the monthly, quarterly and daily-book versions of an instrument.

_months terms always mean calendar months, on every calendar: they describe the contract, not the modeler's grid. {{periods.X}} converts one into a possibly-fractional period count and is for thresholds — five months free rent is 5 periods monthly, 1.667 quarterly, 0.417 annually, and pro-rates exactly in each case. {{whole_periods.X}} is for payment counts that go into pow exponents and annuity term arguments, where a fractional value is meaningless: a 30-month loan is not 2.5 annual payments, so that is E5015_TERM_MONTHS_NOT_DIVISIBLE rather than a rounding. Both need a literal; a term deferred to inputs.<name> cannot be converted at compile time (E5017_PERIOD_TERM_NOT_LITERAL).

The term may belong to a referenced agreement. {{periods.<ref>.term.<name>}}, {{whole_periods.<ref>.term.<name>}} and {{model.accrual_divisor_back.<ref>.term.<name>}} take the same <ref>.term.<name> path that {{contract.<ref>.term.<name>}} reads (§6.4): the <name> term of the contract the term <ref> names, as that contract states it or as a rule of its type defaults it. The months are counted in the periods of the rule that reads them, so a loan's advancing window, the servicing agreement's stop_advance_months, is a count of the loan's payment periods. E5015 applies to the referenced term's months exactly as to a loan's own, and a referenced term that is neither stated nor defaulted is E1376.

Day count. A nominal annual rate becomes a periodic one by dividing, but by what depends on the convention. {{model.accrual_divisor}} reads the contract's day_count term and expands to:

day_countexpands tomeaning
absent, 30/360, 30e/360<ppy>every period is 1/ppy of a year
act/360(1 / year_frac(time.date, edate(time.date, <n>), "act/360"))actual days over a 360-day year
act/365(1 / year_frac(time.date, edate(time.date, <n>), "act/365"))actual days over a 365-day year
act/act(1 / year_frac(time.date, edate(time.date, <n>), "act/act"))ISDA: each part of the span over its own year's length

<n> is the RULE's own length in months — 1, 3 or 12 — not the grid's. A divisor is the reciprocal of a year fraction, so this measures the period the rule actually fires over, which is the period it accrues. That distinction is the whole of the expansion: a quarterly-paying loan accrues a quarter each time it fires, whatever book it is carried on, exactly as {{model.periods_per_year}} resolves to the rule's rhythm rather than the calendar's. Reading time.days_in_period here instead would measure the grid, and did: a quarterly-paying act/360 loan on a monthly grid booked one month of interest per payment — 20,500 where 60,833.33 was right, on 1,000,000 at 6% over 2026.

Dividing by (1 / year_frac) is multiplying by the year fraction, so a 31-day January accrues more than a 28-day February — which is the point of an Actual convention. The default expands to exactly the same text as {{model.periods_per_year}}, and its year fraction is exactly 1/ppy, so 30/360 keeps the cheaper compile-time constant. An unrecognized value is E5019_UNKNOWN_DAY_COUNT rather than a silent fallback: act/360 against act/365 is about 1.4% of interest.

act/act needs a period whose end it can name, so it is accepted for a monthly, quarterly or annual payment cadence and refused by E5019_UNKNOWN_DAY_COUNT for a daily or weekly one — where it would have to be approximated as act/365 and would be wrong by a day every leap year. A daily or weekly rule keeps the older (360 / time.days_in_period) form for act/360 and act/365: such a rule arises only with no schedule_every on a matching calendar, where the rule's period and the grid's are the same period and the two forms agree.

Use it for every nominal rate — note rates, servicing strips, floating index-plus-margin. Do not use it for annual quantities (rent_year, fee_year), which spread by {{model.periods_per_year}} regardless of day count.

Amortization is a second, separate basis. An amortizing loan strikes its level payment once, from a schedule the parties agree, and then accrues interest period by period on whatever the accrual convention says; principal is the plug. Those are two different divisors, and collapsing them makes the payment itself move with month length — which no amortizing instrument does. {{model.amortization_divisor}} reads an amortization_day_count term and expands by the same table as {{model.accrual_divisor}}, defaulting to day_count when absent. So:

  • a rule that uses only {{model.accrual_divisor}} is unchanged;
  • a model that sets only day_count is unchanged, both divisors agreeing;
  • day_count = "act/360" with amortization_day_count = "30/360" is the common US commercial case — a fixed payment, interest varying by month length. The payment is struck ONCE on the amortization divisor and held; a rule does not re-strike it each period on the balance, which moves it by the month's length. credit.loan carries it as a field, the surviving loans' payment, scaled only by survival.

An amortizing rule should therefore strike the annuity factor from {{model.amortization_divisor}}, accrue interest from {{model.accrual_divisor}}, and make scheduled principal the difference. amortization_day_count is validated by the same E5019_UNKNOWN_DAY_COUNT.

Two conventions that must not be confused when dividing by periods-per-year: note rates are nominal and divide (rate / ppy), while CPR and CDR are effective annual and take a root (cpr_to_periodic(x, ppy)). Growth and escalation are effective-annual too and step on {{time.elapsed_years}}.

Substitution is textual: numeric terms yield valid expression fragments; string-valued terms must be quoted inside the template. Example:

[[rules]]
id = "lease_base_rent_v2"
contract_name = "cre.lease"
stream_name = "cre.lease.base_rent"
owner_entity = "${subject}"
direction = "inflow"
amount_expr = "{{contract.base_rent}} * clamp((time.t - {{contract.lease_up.start_period}} + 1) / {{contract.lease_up.months}}, 0, 1)"
schedule_kind = "every"
schedule_from = "{{contract.term_start}}"
schedule_to = "{{contract.term_end}}"

[rules.defaults]
"lease_up.start_period" = "6"
"lease_up.months" = "18"

Declarative domain metrics (metrics.toml)

Packs declare their metric sets via a metrics = "metrics.toml" entrypoint; the engine host evaluates them after a run (cfdl run --pack <name>). Adding a domain means adding a metrics.toml, not Rust. Metrics are evaluated in file order, so a metric reads only those declared above it.

A pack metric is an expression over the finished projection, the shape a model's metric has (docs/01 §15.3) and a valuation figure has:

[[metrics]]
id = "domain.cre.noi"            # the key it publishes under
kind = "money"                   # money | number
op = "expr"
expr = "series_sum(\"domain.cre.noi\", 0, time.t)"

[[metrics]]
id = "domain.credit.servicing"
kind = "money"
op = "expr"
expr = "-(series_sum(\"stream.credit.loan.servicing.*\", 0, time.t))"
require_positive = true          # omit unless value > 0

[[metrics]]
id = "domain.cre.dscr"
kind = "number"
op = "expr"
expr = "domain.cre.noi / domain.cre.debt_service"
  • What it reads. Published series by name: a subtotal ("domain.cre.egi"), a stream ("stream.<name>", or a .* selector that reaches the bare name and every suffixed instance), an account ("account.<name>"), a field, a slice ("slice.<id>"). time.t is the last period of the cash horizon, the length of model.net_cash_flow, and time.ppy the periods in a year, so a metric reads a year as series_sum("<series>", 0, time.ppy - 1). A metric declared EARLIER in the same file is read by the key it publishes under, domain.cre.noi, and the run's own metrics as model.<name>. A later or unknown metric is refused at load, as is an expression that does not compile.
  • When it is omitted. A selector with a * or alternatives that matches nothing contributes its fold's identity, 0 for a sum, as in a model's metric. A series named exactly that the model does not carry, a metric it reads that was omitted, or a result that is not a finite number (a ratio over a zero denominator) omits the metric: publishing 0 would assert a figure nobody computed. require_positive = true also omits it unless it is strictly positive.
  • Lineage. The published lineage's formula is the expression, and numerator_streams lists the metrics and series it read.

Every pack metric is an expression; the fixed operations are retired. sum, negated_sum, ratio and subtotal_total are written series_sum("stream.<selector>", 0, time.t) added or negated, domain.<pack>.<a> / domain.<pack>.<b>, and series_sum("<subtotal>", 0, time.t). A weighted average life over several stream families is wal over one selector whose alternatives name them (docs/01 §16.2):

[[metrics]]
id = "domain.credit.wal_years"
kind = "number"
op = "expr"
expr = "wal(\"stream.credit.loan.sched_principal.* | stream.credit.loan.prepay.* | stream.credit.loan.bullet.* | stream.credit.loan.recoveries.*\")"

wal measures each period's amount at (period + offset) / periods_per_year, the run's calendar and each series' own placement, as a model's metric does.

Engine-universal metrics are computed for every model regardless of pack: model.npv, model.irr, model.payback_periods / model.payback_years (when cumulative net cash turns non-negative, given the model starts cash-negative), and model.wal_years (inflow-weighted average life). There is no whole-model multiple: a multiple partitions cash by KIND, contributed under returned, which the engine cannot know at the model level, so a model states its own — moic(party.<p>) over a party's account, a slice's moic over a declared selection, or a metric over what it counts as invested capital (docs/01 §15.3, §15.4).

npv, irr, wal_years and payback_years are all measured on the same time axis: a flow sits at (period + offset), where offset is its placement in the period.

The time-weighted ones net within an offset, not across one: two flows in the same period at different points in it are not the same cash at the same moment, so a purchase settling on its date does not cancel that period's collections. When every stream shares a placement this reduces exactly to the net cash-flow series.