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.
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 MUST declare supported compiler IR versions.
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
validations.toml
defaults.toml
outputs.toml
README.md
opco/
pack.toml
...
5.1 Required manifest (pack.toml)
A pack directory MUST include pack.toml:
pack_id = "cre"
version = "0.1"
description = "Commercial Real Estate domain pack"
ir_versions = ["0.1"]
[entrypoints]
types = "ontology/types.toml"
aliases = "aliases.toml"
contract_schemas = "contracts/schemas.toml"
lowering_rules = "contracts/lowering.toml"
outputs = "outputs.toml"
docs = "docs/index.toml"Rules:
pack_id,version,ir_versions, andentrypoints.typesare REQUIRED.entrypoints.outputsis REQUIRED for packs that define domain-specific metrics and aggregations.- Other entrypoints are optional.
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:
{
"types": [
{
"type_id": "CRE.Asset",
"kind": "entity",
"fields": {
"city": {"type": "String", "required": false},
"units": {"type": "Int", "required": false}
}
}
]
}Type registry semantics:
- Packs MUST NOT remove core types.
- Packs MAY extend fields.
- Packs MAY provide documentation strings and examples.
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 term schemas
A pack MAY provide schemas for contract terms per TypeId.
Example:
{
"contracts": [
{
"type_id": "Contract.Lease",
"terms": {
"base_rent": {"type": "Money", "required": true},
"rent_growth": {"type": "Rate", "required": false},
"start_date": {"type": "Date", "required": false}
}
}
]
}Compiler usage:
- If schema exists, the compiler MUST validate required terms and types.
- Schema validation errors are
E4003_INVALID_CONTRACT_TERMS.
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": "${contract.currency}",
"schedule": {"kind": "Every", "every": "monthly", "on_rule": {"kind": "EndOfMonth"}},
"amount_expr": {"lang": "cfdl", "src": "terms.base_rent"}
}
]
}
]
}Template rules:
${subject}resolves to the contract subject entity symbol.${contract.currency}resolves to the contract currency.
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 Ontology observable and reference IDs
Packs define canonical IDs for:
obs.rate(<name>),obs.index(<name>),obs.fx(<from>, <to>)ref.<name>
Packs MAY provide registries:
observables.jsonrefs.json
Compiler behavior:
- If pack provides registries, the compiler MAY validate that referenced IDs exist.
- Missing observable IDs SHOULD be warnings in v0.1 (allow offline modeling).
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, the check, a stable diagnostic code, and a
message. Available checks: term_present, any_term_present, 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, and term_compare. 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
Packs SHOULD provide docs for:
- types and fields
- contract templates
- output definitions
- examples
Editors may use this for hover hints and snippet insertion.
6.10 Output specification (engine-computed metrics and aggregations)
A pack MUST provide an output specification if it defines domain-specific metrics. Output metrics (NPV, IRR, NOI, DSCR, etc.) are NOT defined in CFDL — they are computed by the engine based on the pack's output spec.
Core principle: CFDL defines time, structure, and behavior (streams, events, options). The engine reads the IR + the pack output spec and produces domain-appropriate results. Different packs produce different output sets from the same IR.
6.10.1 Stream categorization
The output spec defines categories that group streams by semantic purpose. Categories match streams using:
- Contract type + direction: For streams inside contract
effectsblocks - Stream name prefix + direction: For standalone streams
[categories.operating_revenue]
description = "Operating revenue streams"
match = [
{ contract_type = "Contract.Lease", direction = "inflow" },
{ stream_prefix = "cre.ops_revenue", direction = "inflow" },
{ stream_prefix = "cre.projected_leaseup", direction = "inflow" }
]
[categories.operating_expense]
description = "Operating expenses"
match = [
{ contract_type = "Contract.OperatingExpense", direction = "outflow" },
{ stream_prefix = "cre.ops_expense", direction = "outflow" }
]
[categories.debt_service]
description = "Debt service payments"
match = [
{ contract_type = "Contract.Loan", direction = "outflow" },
{ contract_type = "Contract.ConstructionLoan", direction = "outflow" }
]
[categories.exit]
description = "Exit/disposition proceeds"
match = [
{ contract_type = "Contract.ExitCap", direction = "inflow" }
]Matching rules:
- A stream matches a category if ANY match rule is satisfied.
- A stream MAY match multiple categories (engine resolves by priority).
- Streams that match no category still appear in net cashflows.
6.10.2 Aggregations
Aggregations combine categories into domain-meaningful time series:
[aggregations.noi]
description = "Net Operating Income"
formula = "categories.operating_revenue - categories.operating_expense"
frequency = "period"
[aggregations.cfads]
description = "Cash Flow Available for Debt Service"
formula = "aggregations.noi - categories.debt_service"
frequency = "period"
[aggregations.net_cashflow]
description = "Total net cashflows across all streams"
formula = "sum(all_streams)"
frequency = "period"Aggregation rules:
- Aggregations produce per-period time series in the Results
seriesmap. - Aggregations MAY reference other aggregations (engine resolves order).
- The engine MUST detect circular references and emit an error.
6.10.3 Ratios
Ratios express relationships between aggregations:
[ratios.dscr]
description = "Debt Service Coverage Ratio"
numerator = "aggregations.noi"
denominator = "categories.debt_service"
frequency = "period"Ratio rules:
- Ratios produce per-period time series.
- Division by zero SHOULD emit a warning and produce
nullfor that period.
6.10.4 Derived values
Derived values are domain-specific computations the engine performs:
[derived.terminal_value]
description = "Exit sale proceeds from cap rate valuation"
kind = "exit_cap"
noi_source = "aggregations.noi"
cap_rate_term = "exit_cap"
schedule = "on_exit"Derived rules:
- The
kindfield tells the engine which computation to apply. - Inputs reference aggregations, categories, or contract terms by name.
- Derived values may insert additional cashflows into the model (e.g., terminal value).
6.10.5 Summary metrics
Summary metrics are computed from aggregated series or net cashflows:
[metrics.npv]
description = "Net Present Value"
source = "aggregations.net_cashflow"
method = "discount"
discount = "assume.discount_rate"
[metrics.irr]
description = "Internal Rate of Return"
source = "aggregations.net_cashflow"
method = "solve_irr"Metric rules:
- Metrics produce scalar values in the Results
metricsmap. - For Monte Carlo runs, metrics produce
MetricSummarydistributions. - The engine MUST support at minimum:
discount(NPV),solve_irr(IRR). - Packs MAY define custom method names if the engine supports them.
7) Compiler ↔ Pack API (programmatic)
7.1 Pack loader interface
The compiler should expose a minimal interface:
load_pack(pack_id, 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:
- ID generation seed includes
pack_id@versionif present.
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:
examples/<pack>/...modelsfixtures/valid/...that use 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, and schedule_to may
contain {{contract.<key>}} 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 placeholder with no contract value and no default is a compile error
(E5006_MISSING_CONTRACT_TERM), one diagnostic per missing key.
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"
currency = "USD"
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 ratios may reference earlier ids.
[[metrics]]
id = "domain.cre.noi" # output key
kind = "money" # money | number
op = "sum" # sum | negated_sum | ratio | wal_years
numerator_streams = ["cre.lease.base_rent", "cre.ops.revenue"]
denominator_streams = ["cre.ops.expense"]
formula = "sum(numerator_streams) + sum(denominator_streams)" # lineage text
[[metrics]]
id = "domain.cre.debt_service"
kind = "money"
op = "negated_sum"
numerator_streams = ["loan.permanent_debt_service"]
formula = "-sum(numerator_streams)"
require_positive = true # omit unless value > 0
[[metrics]]
id = "domain.cre.dscr"
kind = "number"
op = "ratio"
numerator_metric = "domain.cre.noi"
denominator_metric = "domain.cre.debt_service"
formula = "domain.cre.noi / domain.cre.debt_service"
# ratios are omitted when either input metric is absent or the denominator ~ 0
[[metrics]]
id = "domain.credit.wal_years"
kind = "number" # wal_years requires kind = "number"
op = "wal_years"
numerator_streams = ["credit.pool.sched_principal.*", "credit.pool.prepay.*"]
formula = "wal_years(numerator_streams)"
# weighted average life in years of the matched streams' positive per-period
# amounts: sum(t/ppy * v) / sum(v), using the engine's run.periods_per_year;
# omitted when the matched streams have no positive amountsEngine-universal metrics are computed for every model regardless of pack:
model.npv, model.irr, model.moic, model.payback_periods /
model.payback_years (first period cumulative net cash turns non-negative,
when the model starts cash-negative), and model.wal_years (inflow-weighted
average life). All operate on the net cash-flow series.