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 packMAY appear only inmodel.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
nameinpack.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:
nameandversionare REQUIRED.descriptionis optional.cadencesis 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 isE5013_PACK_CADENCE_UNSUPPORTED. A single rule may narrow this further with its owncadences(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,validationsandontology, each a path relative to the pack directory. An unrecognized key is accepted and ignored, so check spelling:packs/cre/pack.tomlonce declareddefaults = "defaults.toml", which the loader has no field for, and the file sat unread. versionis matched against the model'suse pack "<name>" version "<v>"by exact string equality — there is no semver range logic. A pack present at a different version reportsE4004_MISSING_PACKnaming 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 masterRecorded 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 aContainer.*— so every pack entity type statesrefines. - The family and the class are what the chain implies. A type states neither: its family is the master's —
assetunderAsset.*,partyunderParty,referenceunderReference,containerunderContainer.*— and an asset's class is the master's name (Asset.Realisreal). Afamilyorclasskey intypes.tomlis refused at load, and so is a type whose own name claims a family its chain does not reach (CRE.Party.XrefiningAsset.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.Unitmakes onrentable_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
guardis 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
guardmakes the edge self-driving, evaluated each period an entity of the type is infrom— reading series strictly backward, the same rule a model-declared edge'swhenfollows, 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'sin <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]]withset,valueand a selection on its terms (when,defaults) as a rule has, as a model'son enddoes (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 ownon end. A write of the state the subject is already in moves nothing and warns nothing. - A lifecycle MAY
refinesa master machine (core.debt), renaming the master's states with[[lifecycles.refined_states]](name,refines) and stating nostatesorinitialof 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'sstate <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
leasedand 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
statusacross 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 valueoverridden, naming its author. setwrites a FIELD and neverstatus, 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.
setmay name the ACCOUNT a master declares:set balance = 0on an entity's machine. The entity carries<entity>.balancebecause its contract opened it, so the write is a movement of the opening balance to that value — a write-off on repurchase — journaled as amovefrom the machine, and every row readingprev.balancethat period reads the new opening. A FIELD ROLE (field_role = "balance"besidefield_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_nextin §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 stateinitial,stateor 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 journalsoverridden.
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 = trueA 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 = trueA 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
effectsto 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:
ownerSHOULD 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 toowner_entityalone.
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_dayis a model'son day <n>oron 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_conventionandschedule_calendarroll the due date off a weekend or holiday, as a model'sconventionandcalendardo.schedule_exceptandschedule_alsoremove and add dates, as a model'sexcept [..]andalso [..]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
paymentclause replaces the rule's day, as it replaces the rule's placement; the roll still applies. Anon_daterule takesschedule_conventionandschedule_calendaronly, as a model'sschedule ondoes.
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 defaultsamortization = "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_unstatedonly 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 wheninterest_rateis stated and floating whenindexis.when_stated_anyapplies 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_TERMSnames 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'sside: a subject thatpaysowes the balance, one thatreceivesis due it, and a type opening an account must declare one. A declaration may state its ownside = "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.initis 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_unstatedselect as on a rule.accounton 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 typeaccount,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'sprincipal_accountis. Aninfloworoutflowmoves it by the account's side;accrualraises andwriteofflowers 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 asprev.<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.advancesless 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 amovefrom the machine:on enter retired { set balance = 0 }writes the whole opening off, and every row readingprev.balancethat period reads zero. A machine a CONTRACT binds sets nothing (docs/01§8.6): it lists a write-off line in the state instead, ascredit.loan'szero_balanceruns only inprepaidandrepurchased. 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_idand is not one ofexcept_types, and that the declaration SELECTS:when_statednames literals the entity must state,when_unstatedliterals 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 areE5021. {{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 isE5006, 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 asoccupancy = 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 asmetric.<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 ownmetricmay read them too. -
reach_yearsis how far past the contract's date the figures fold. A timeline whoseprojecttail 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 = trueon a figure asks the same appraisal at each period of the cash horizon, withtime.tthe valuation date, and publishes the seriesmetric.<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"publishesmetric.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_statedandwhen_unstated:cre.exitcarries one valuation perbasis, each with its ownreach_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 foldsaccount.{{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
principaldefaults 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: astatuswrite takes an edge the subject's machine declares. - The schedule takes the keys a rule's does (
schedule_kindofeveryoron_date,schedule_every,schedule_from,schedule_to,schedule_on_day,schedule_convention,schedule_calendar,schedule_except,schedule_also). With noschedule_kind, the event fires on its condition's rising edge. An event states a schedule, awhen, 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_typeslimits the event to contracts whose subject is, or refines, one of the listed entity types:cre.lease_unitvacates its unit when a termination ends it early, and a unit lease on a building has no unit to vacate.when_stated,when_unstatedandwhen_stated_anyselect the contracts that carry the event, as on a rule:cre.spec_leaseopens a unit's re-let with an event only where it states noarea, 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]]isseton that entity: a field, orstatus. An action may name another node withentity, templated:entity = "contract.{{contract.name}}"moves the agreement's own machine (docs/01§8), as a covenant test moves a loan intobreach. 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
payofftakes the pack's, with itscategory; 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 isE1372. 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 itsbalance.- An action is
set(anentity, afieldorstatus, avalue),activateordeactivate(astream), 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, inseniorityorder, 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,amountandaccountexpanded 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
accountnames. Where it is absent, or reads a term the contract does not state, the step pays the party the contract binds in the line'spaid_torole. amountis an ordinary step amount (docs/01§10.3): it may readremaining, and the engine paysmin(max(0, amount), remaining).pari_passu = truemakes the steps one rule contributes for one seniority share what is left pro rata by their amounts: step i paysmin(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_unstatedandwhen_stated_anyselect as on a rule: a loan'strapline holds the cash in its lender'strapaccount on a cash trap and pays it to itssweptaccount 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
E500xand 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 CREE7xxx_*for OpCoE8xxx_*for EnergyE9xxx_*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 key | meaning |
|---|---|
template | the template the part renders, by id: a contract, a part, or another shape |
each | a list parameter of the shape, comma-separated; the part renders once per item |
as | the parameter each item binds; ${index} is its position from 1 |
when | the shape's parameters the part requires, as [rules.when] selects a rule |
params | parameters the part's template receives, themselves rendered with the shape's |
- Choices.
[templates.choices]lists the values a parameter admits. A CRE strategy takespropertyand 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,startandcalendaramong 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
eachorwhennaming a parameter the shape does not default, aneachwith noas, a choice whose default it does not admit, andpartsorchoiceson 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"| field | meaning |
|---|---|
id | output key; must start with domain. |
kind | money or number |
op | sum, negated_sum, ratio |
categories | selectors to aggregate; operating.* matches the prefix and its children |
streams | stream-name selectors, for what a category cannot express |
subtotals | other subtotals to add, declared above this one |
numerator / denominator | for op = "ratio" |
formula | a 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) -> PackPack.type_registry() -> TypeRegistryPack.aliases() -> AliasRegistryPack.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_PACKwith details - Unsupported IR version:
E4004_MISSING_PACKwith 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:
{{contract.term_start}}/{{contract.term_end}}— the contract'sterm A..Brange (normalized dates).{{contract.<key>}}— the contract'sterms { <key> = <value> }entry (dotted keys likelease_up.monthsare supported).- 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 ispart 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. - 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 aprev.read or a rule'saccountkey;{{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'sadvancing;.term.name,.term.suffix_ident,.term.term_startand.term.term_endare 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 ismetric.{{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.
| Placeholder | Expands 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_count | expands to | meaning |
|---|---|---|
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_countis unchanged, both divisors agreeing; day_count = "act/360"withamortization_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.loancarries 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.tis the last period of the cash horizon, the length ofmodel.net_cash_flow, andtime.ppythe periods in a year, so a metric reads a year asseries_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 asmodel.<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 = truealso omits it unless it is strictly positive. - Lineage. The published lineage's
formulais the expression, andnumerator_streamslists 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.