Policy IR & Economic Terms
How does a fund’s LPA — “2.5% on committed capital during the investment
period, stepping down 25 bps/yr to a 1.5% floor; 20% carry, escalating to 25%
above 3×” — become something the close engines can execute deterministically?
The answer is a typed intermediate representation (IR) stored directly as
the terms payload — directly, because any separately-persisted “compiled”
form is a second truth that can drift from the first. This page covers ADR-0019 (“the payload IS the IR”) and the
IR schemas in packages/fund-economics/src/.
One truth: vehicle_terms_versions.payload
The canonical economic-policy layer is a single versioned store:
- Where:
vehicle_terms_versions.payload— typed Effect-Schema IRs from@opsdna/fund-economics, stored as schema-registered (fund_economic_policy_payload_registry), canonically-encoded,payload_hashed JSON. - Scoped by
terms_kind∈lpa/side_letter/management_fee/waterfall/expense_allocation/pcap_policy, optionally bypartner_class_id. - No persisted compiled step. “Compilation” is decode → validate → lower to engine inputs at run time, deterministic and pinned. The original design’s compiled engine tables were never fed by production code and were dropped (ADR-0019 §5) — the payload was the only load-bearing layer.
Effect Schema is the single definition language because one definition yields every projection: decode (validation), encode (the canonical bytes the determinism hashes cover), the TypeScript types the engines execute, the JSON Schema handed to extraction models, and the registry entry that rejects unregistered shapes.
The IR schemas live in packages/fund-economics/src/:
management-fee-policy-ir.ts, waterfall-policy-ir.ts
(WATERFALL_POLICY_SCHEMA_VERSION is 5 — selector branches × a nested
carry_schedule over a shared MetricThreshold vocabulary, per ADR-0011’s
marginal-carry amendment), expense-allocation-policy-ir.ts,
partner-override-ir.ts, plus the shared grammar in economic-grammar.ts,
period-selector.ts, and fee-rate-schedule-selector.ts.
Queryability is a projection, never a second store
“Find every fund where carry > 25%” is answered by fund_terms_index —
flattened rows derived deterministically from the payload at approval time,
rebuildable in full at any moment. It is read by search/reporting surfaces
only. Engines never read it, enforced by the package-boundary scanner (a
banned import for packages/fund-economics/src/runtime/ and the close
builders) plus a posture test. The index answers “what is true now”; “what did
we know at approval time” is answered by the versioned payload rows themselves.
Provenance: the run→terms link set
One close consumes several approved terms versions concurrently (fee policy +
waterfall + expense allocation + the LPA + side letters). So the pin is not a
single FK but a link set: calculation_run_terms_versions — one row per
terms version the run consumed, written at run creation, immutable thereafter,
with a coverage invariant asserted by the derivation-chain meta-test. Replay
semantics are unchanged: input_hash already pins the exact terms bytes; the
link set makes the pinned thing navigable.
Profiles fail closed
The grammar stays fully general (other strategies someday); what a given fund
may author is governed by its economic profile, default venture, which
refuses what ADR-0011 rules out — preferred-return/catch-up state accounts and
hurdle-shaped tiers (recognized by shape, regardless of authored rate) and
excused-investor expense carve-outs. The behavior contract:
- Refuse loudly:
unsupported_for_profilevalidation diagnostics with paths — never silently dropped, never silently computed. - Capture faithfully: constructs the engine cannot yet run are labeled
normalizable_but_not_executable(e.g. a carry conditioned on related-fund facts normalizes now and flips to executable once the fact source is wired). - Re-enabling a refused lever for a future strategy (a PE profile with a hurdle) is profile work plus engine validation, never a grammar change.
Implementation: packages/fund-economics/src/economic-profile-gate.ts; the
executability firewall is executableApprovalIssues in
policy-draft-envelope.ts, which today enforces feature support ∧ fact
wiring (a draft needing an unwired fact reports non-empty missingFacts).
Ingestion: the model never composes the tree
The PDF lane extracts toward the same AST the wizard edits — two producers, one reviewed, evidence-cited draft. The load-bearing design points (ADR-0019 §7): clause segmentation → defined-term harvest (the document’s symbol table) → per-clause micro-extractions against small sub-schemas, every leaf anchored to an evidence span → deterministic assembly in ordinary tested TypeScript (which rejects unanchored leaves outright) → the validation pyramid → human review → approval. Span coverage is total: every span of an economically relevant section ends mapped, intentionally ignored, or review-blocking — an unresolved span blocks approval. Correctness is judged empirically by an end-to-end eval harness (page image → draft IR → Acceptance Lab close → statement tie-out), not in code review.
Pitfalls.
- Never introduce a parallel unregistered JSONB terms shape — new primitives enter only via schema-version bump + registry + golden fixtures.
- Never hand-feed a queryable terms table — queryability is a rebuildable
projection, and an engine consulting
fund_terms_indexfails at lint time by design. - Never cite a
fund_terms_indexrow as historical evidence — it is a current projection, full-replaced on rebuild. - Never encode economics on
partner_role— carry rate, net/gross basis, and fee schedule arepartner_classattributes resolving throughvehicle_terms_versions(ADR-0011, DATA-MODEL rule 3).
Related: Application Records (what the
lowered IR produces at close time), Entity Model
(partner_class, vehicles, and who owns economics).