Canonical Application Records
Every fund-economics calculation decision — a management-fee adjustment, a
waterfall branch selection, a tier execution, a carry-rate resolution — is
emitted as a canonical <Domain>Application record. This page explains the
contract (ADR-0001), the three-surface separation that keeps runtime,
persistence, and traces from bleeding into each other (ADR-0002), and shows a
real domain end-to-end.
Why three surfaces
Before ADR-0001/0002, calculation records, persistence rows, and trace bundles shared type names. That made migrations risky: a field added for trace evidence could look like a storage contract; a row projection could look like the semantic runtime record. The fix is one canonical record per decision, with persistence and traces as derivations of it:
Neither the SQL row nor the trace report is the source of truth for the decision — the canonical record is.
The contract (ADR-0001)
The contract is structural, not nominal — there is no base class or shared generic supertype. Each domain owns its own opaque interface and Effect Schema decoder, and conforms by carrying the mandatory fields:
| Field | Meaning |
|---|---|
schema_version: number | Bumped on any breaking field-shape change |
application_hash | Canonical hash over the record bytes (excluding itself), formatted <kind>:vN:<sha256> |
kind | A Schema.Literal, e.g. "waterfall_tier_application" — the cross-domain dispatch surface |
diagnostics | A closed per-domain union — code: string is banned |
evidence_refs | References to supporting evidence |
posting_intent | "shipped" | "shadow" | null |
Traces (<Domain>ApplicationTrace) wrap the canonical records with:
applications_hash (a payloadHash over the applied array bytes),
applied (the canonical records that completed), diagnostics, optional
input_trace_versions, and optional structured skipped records with
closed-union skip reasons. Error traces still emit — if a calculation
aborts mid-run, applied contains whatever completed and a diagnostic carries
the abort reason.
A real domain: waterfall tier evaluation
packages/fund-economics/src/runtime/waterfall-tier-evaluation.ts walks the
ordered tier sequence of a distribution waterfall, routing proceeds through
each tier, applying state-account caps (e.g. return-of-capital), and resolving
carry-rate refs against approved, source-backed definitions — failing
closed on unreviewed rates. Its surface shows every part of the contract:
// packages/fund-economics/src/runtime/waterfall-tier-evaluation.ts
export const WATERFALL_TIER_APPLICATION_KIND =
"waterfall_tier_application" as const
// Closed-union diagnostic surface for the tier evaluator.
export const WATERFALL_TIER_EVAL_DIAGNOSTIC_CODES = [
"missing_state_account",
"missing_prior_state",
"insufficient_proceeds_for_tier",
"missing_carry_rate_value",
"unapproved_carry_rate",
"unsupported_tier_kind",
"state_account_negative",
] as const
export const CarryRateResolutionApplicationSchema = Schema.Struct({
schema_version: Schema.Literal(CARRY_RATE_RESOLUTION_APPLICATION_SCHEMA_VERSION),
application_hash: NonEmptyStringSchema,
kind: Schema.Literal(CARRY_RATE_RESOLUTION_APPLICATION_KIND),
diagnostics: Schema.Array(CarryRateResolutionDiagnosticSchema),
evidence_refs: Schema.Array(CarryRateResolutionEvidenceRefSchema),
posting_intent: CarryRateResolutionPostingIntentSchema,
tier_id: IdSchema,
carry_rate_ref: NonEmptyStringSchema,
resolved_value: RatioValueSchema,
approval_status: Schema.Literals(["approved", "pending", "unapproved"] as const),
lookup_chain: Schema.Array(CarryRateLookupStepSchema).check(Schema.isMinLength(1)),
applied_modifier_refs: Schema.Array(AppliedEconomicModifierRefSchema),
// ...domain fields elided
})Determinism is explicit in the file’s header comment: “pure over inputs.
Every money / ratio operation goes through exact-decimal BigInt helpers; no
IEEE float arithmetic.” The evaluator also emits a per-tier
WaterfallTierArithmeticTrace in which every money value carries its
canonical-decimal projection plus the decimal-runtime version, so a hash
re-computation can prove the trace’s arithmetic was equivalent to the
original.
The sha-pinned contract demonstrations live at
packages/fund-economics/src/runtime/examples/ (management-fee adjustment and
waterfall branch) — ADR-0001 pins their hashes so reviewers can tell when the
demonstration changed.
The persistence side (ADR-0002)
Adapters at packages/persistence/src/adapters/<domain>-application-row-adapter.ts
export the row schema, <DOMAIN>_ROW_PROJECTED_FIELDS, and the pair
to<Domain>ApplicationRow / from<Domain>ApplicationRow, bound by two tested
identities:
- Forward:
from(to(c))equalspick(c, PROJECTED_FIELDS). - Reverse:
to(from(r))canonical-JSON byte-equalsr.
Write repositories then persist through a header + detail layout
(ADR-0001 addendum): a thin economic_adjustment_applications header carrying
only the fields every domain shares, plus a per-domain detail table
discriminated by kind. Each domain registers a write plugin:
// packages/persistence/src/waterfall-application-write-repository.ts
export const WATERFALL_TIER_APPLICATION_HASH_VERSION =
"waterfall_tier_application:v1" as const
export const WATERFALL_TIER_APPLICATION_WRITE_PLUGIN = {
domainKind: "waterfall_tier",
canonicalKind: WATERFALL_TIER_APPLICATION_KIND,
schemaId: WATERFALL_TIER_APPLICATION_HASH_VERSION,
schemaVersion: WATERFALL_TIER_APPLICATION_SCHEMA_VERSION,
detailTable: "economics.waterfall_tier_application_detail",
detailRowSchema: WaterfallTierApplicationRowSchema,
toDetailRow: toWaterfallTierApplicationDetailRow,
fromDetailRow: fromWaterfallTierApplicationRow,
sourceEventContract: WATERFALL_DISTRIBUTION_SOURCE_EVENT_CONTRACT,
hashPayload: ({ tenant_id, calc_run_id, canonical_kind, schema_version, detail_row }) => ({
v: WATERFALL_TIER_APPLICATION_HASH_VERSION,
tenant_id, calc_run_id, kind: canonical_kind, schema_version, detail_row,
}),
} as constThe sourceEventContract pins the payload refs a waterfall_distribution
source event must carry (output_hash, dependency_snapshot_hash,
policy_version_ref, idempotency_key, …) — connecting the application
records to the source pipeline provenance chain.
Cross-domain consumers dispatch on kind
Generic consumers (PCAP cell projection, the close-control tie harness, the
generic write-repo plugin interface) accept a discriminated union keyed on
kind with a never-default exhaustive branch. Adding a new kind literal
requires the new Schema.Literal, a union arm in every generic consumer, the
header kind CHECK extension, and a fixture — so an unhandled kind fails loud
at both type-check and runtime registration.
Pitfalls.
- Do not import row types into
packages/fund-economics/src/runtime/, and do not import*Application/*Tracetypes into non-adapter persistence source. Boundary tests enforce this (ADR-0002). - Do not introduce a shared
*Applicationsupertype or base class “for convenience” — ADR-0001’s 2026-05-30 addendum exists specifically to stop that refactor. The contract is structural. - Do not add a free-form
code: stringdiagnostic. Diagnostics are closed unions; a new code means updating the union and a fixture. - Schema-version bumps are real events: any breaking field-shape change flips the version and requires the fixture-update process.
Related: Policy IR (where the evaluators’ inputs come from) and Persistence (repository and migration conventions).