Skip to Content
Fund AccountingCanonical Application Records

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:

FieldMeaning
schema_version: numberBumped on any breaking field-shape change
application_hashCanonical hash over the record bytes (excluding itself), formatted <kind>:vN:<sha256>
kindA Schema.Literal, e.g. "waterfall_tier_application" — the cross-domain dispatch surface
diagnosticsA closed per-domain union — code: string is banned
evidence_refsReferences 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)) equals pick(c, PROJECTED_FIELDS).
  • Reverse: to(from(r)) canonical-JSON byte-equals r.

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 const

The 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/*Trace types into non-adapter persistence source. Boundary tests enforce this (ADR-0002).
  • Do not introduce a shared *Application supertype 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: string diagnostic. 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).

Last updated on