Ledger Kernel & Money Discipline
packages/ledger is the double-entry core: the kernel that turns source events
into balanced, dimensioned journal entries, the money types that make float
arithmetic unrepresentable — because a GL that can drift by a rounding cent is
one an auditor cannot trust — and the ASC 820 disclosure builders. This page
also covers where per-LP balances live (spoiler: in a projection, not a second
ledger).
The kernel (packages/ledger/src/kernel.ts)
The kernel (LEDGER_CODE_VERSION = "m9-ledger-kernel-v1") operates on an
in-memory LedgerState (accounts, books, dimensions, journal entries, source
events, reconciliations — see schema.ts) and exposes posting plans:
typed, pre-computed descriptions of a posting that carry their own provenance
and rounding facts before any row is written.
// packages/ledger/src/kernel.ts
export interface CapitalCallIssuedPostingPlan {
readonly sourceEventId: string
readonly sourceEventAmountCents: number
readonly ruleSetId: string | null
readonly ruleSetVersion: number
readonly allocatorKind: RuleSet["allocator_kind"]
readonly materialityThresholdCents: number
readonly allocationRuleId: string
readonly allocations: ReadonlyArray<ContributionAllocation> // per-investor,
// each with amountCents, committedAmountCents, roundingRemainderCents
readonly inputHash: string
}A plan pins the rule-set id and version, the allocation rule, the
per-investor split including each investor’s rounding remainder, and an
inputHash — so the posting is replayable and the largest-remainder cent
distribution is auditable line by line. Domain-specific posting logic lives in
src/posting-compilers/ (fair-value adjustments, capital calls, etc.), with
preflight checks in src/posting-preflight/ and leg-shape/dimension
discipline asserted by posting-leg-shape.ts,
account-dimension-rules.ts, and dimension-assignment-provenance.ts.
Two other kernel disciplines worth knowing:
- Deterministic account ids. Canonical accounts get uuid-v5 ids derived
from
canonical:<kind>namespaced under the accounting entity (canonicalCatalogAccountId), so every seeding path, posting dispatcher, and available-accounts list mints one idempotent id per(entity, kind)— and a later SPV gets its own chart, not a collision. The id is opaque; the kind lives incanonicalAccountKind, never parsed out of the id. - Dimension snapshots are verified. Journal-line dimension snapshots are
checked against normalized links with a typed issue taxonomy
(
missing_dimension_value,snapshot_value_mismatch, …) — denormalization for read speed, with an invariant checker instead of blind trust.
Money: branded bigint, never number
packages/ledger/src/money.ts defines the canonical money/ratio/quantity
types. Backing storage is bigint at a fixed decimal scale — never
number — and the brand makes raw bigints and numbers unassignable without
a constructor:
export const MONEY_SCALE = 4n as const // sub-cent precision, ×10^4
export const RATIO_SCALE = 12n as const // bp-fractional precision, ×10^12
export type Money = bigint & { readonly [MoneyBrand]: void }
// Money.mul: product is at scale 16; round back to 4, default banker's.
mul: (m: Money, r: Ratio, mode: RoundingMode = "half-even"): Money => { ... }
// Money.divToParts: split total across weights so sum(parts) === total
// EXACTLY — largest-remainder method, ties broken by ascending index;
// signed totals handled by reflection so reversals conserve too.
divToParts: (total: Money, weights: readonly Ratio[]): Money[] => { ... }divToParts is the cent-remainder rule that makes pro-rata allocations
conserve to the cent — the same discipline the posting plans record as
roundingRemainderCents. The fund-economics runtime uses the parallel
exact-decimal layer (packages/fund-economics/src/exact-decimal.ts over
MoneyValue/RatioValue from @opsdna/fund-domain), and evaluator traces
embed each value’s canonical-decimal projection plus the decimal-runtime
version so trace arithmetic is provably reproducible.
Where per-LP balances live: the PCAP projection (ADR-0010)
Per-LP capital accounts look like they live in two places. They don’t — there are three layers, not two competing stores:
The two canonical homes hold two kinds of fact, not two copies of one fact:
a carry accrual is a net-0 inter-partner reallocation that is off the P&L
and absent from posted journals (ADR-0009) — the subledger is its single
correct home. The projection exists so the read model has one uniform place to
read, and drift is non-silent by construction: every projected row carries
source_hash + source_watermark, so a changed canonical source makes the
row known-stale and rebuildable, never quietly wrong.
Statements (packages/fund-statements-builder, consumed by the Acceptance
Lab’s fund-statements-builder.ts) render P&L, balance sheet, and per-LP PCAP
from the GL and these projections; ASC 820 disclosure sheets
(asc-820-level-3-rollforward.ts, soi-sheet.ts, partners-capital-sheet.ts
in packages/ledger/src/) derive from the marking register — see
Accounting Foundations.
Pitfalls.
amount: numberis the anti-pattern theMoneybrand exists to kill. Never route monetary values through IEEE floats; construct viamoneyFromCents/moneyFromStringand keep arithmetic in the branded ops.- Never treat a projected PCAP row as authoritative, and never reconcile two projected rows against each other as if either were truth (ADR-0010 rule 1).
- Each component has exactly one canonical home. “Dimensioning a compiler”
means changing a component’s canonical home and adding a
journal_lineproducer — never adding a second per-LP write path. - Posted journal lines are immutable; migrations are forward-only. Schema
changes to the ledger need the real-Postgres integration lane, not just
pglite
verify(see Persistence).