Skip to content
CommonLayer
Documentation menu
On this page

Ledger specification

This is the specification of CommonLayer's financial ledger as implemented in crates/commonlayer-funding and migrations 0010_funding.sql and 0020_funding_hardening.sql: the accounts, the transaction kinds and their idempotency keys, the invariants and where each is enforced, the settlement algorithm, refunds and disputes, obligations and payouts, and reconciliation. It is written for people who need to decide whether to trust the platform with money, so it also says what is not verified.

For the model in plain terms, read the funding model first.

Status of the implementation

AreaStateEvidence
Posting API, invariants, triggersImplementedtests/it/ledger.rs; database tests in crates/commonlayer-database/tests/it/invariants.rs
SettlementImplemented as a pure function plus persistencethe nine worked examples as unit tests; two property tests (1 500 cases each); tests/it/cycles.rs
Contributions, webhooks, refunds, disputesImplementedtests/it/contributions.rs, webhooks.rs, refunds.rs (duplicate, reordered and 12 concurrent deliveries of one event)
Obligations, payout runs, rewardsImplementedtests/it/obligations.rs, payouts.rs, rewards.rs (6 concurrent approvals on one pool: exactly the affordable 2 are funded)
Reconciliation, verify_ledgerImplementedtests/it/reconciliation.rs; every database test ends with a ledger soundness check
Zero-sum propertyTestedtests/it/properties.rs: random sequences of 24 operations with real fees; invariants checked after every step
Fifteen-step scenario of the product briefTestedtests/it/scenario.rs
Review fixes (HIGH-1, -3, -4, -5, MEDIUM-4, -6, LOW-1 to -4)Implementedtests/it/hardening.rs, stripe.rs, wp70.rs (docs/status/WP-70b.md names each test)
Stripe, including invoice-payment linking for subscription refunds and disputesImplemented; not run against a live accounttests/it/stripe.rs uses fixtures built from the API reference and a mock server
Live payouts (Stripe Connect)Implemented; only exercised against a mocktests/it/payouts.rs and hardening.rs use the simulated provider
Composed systemTestedacceptance test workflow_b_funding (settlement through jobs and signed simulator webhooks) and workflow_d_replacement (a paid simulated payout run, replacement with no ledger effect)
clctl ledger verifyImplemented (also --remote over gRPC)ran against the seeded development database, the Compose stack and restored backups: "ledger sound" (WP-40, WP-41, WP-70g)
Production operationNone—

Money

Amounts are Money { amount_minor: i64, currency } with checked arithmetic. Splits use the largest-remainder method, so parts always sum to the whole. Floating point is forbidden in the crate (#![deny(clippy::float_arithmetic)]). A project treasury has exactly one currency; posting entries of different currencies in one transaction is refused (funding.currency_mismatch), and the database refuses an entry whose currency differs from either account (CL008). There is no currency conversion.

Accounts

balance(account) = Σ credited − Σ debited. Every entry has both sides, so the sum of all balances in a currency is zero by construction.

OwnerKindClassMeaning
worldcontributionsexternalSource of all confirmed money in; its balance is minus the total ever received
worldpayment_feesexternalProvider fees
worldrefundsexternalMoney returned to contributors (refunds and disputes)
worldadjustmentsexternalCounter-account for audited manual corrections
platforminfrastructure_treasuryinternalShared infrastructure funds
platforminfrastructure_spentexternalInfrastructure money paid to vendors
projectavailableinternalUnallocated project funds
projectflexible_poolinternalCommunity rewards and emergency work
projectreserveinternalContinuity reserve
projectobligationsinternalOwed to named payees, not yet paid
projectpaid_outexternalCumulative compensation paid by the project

Internal accounts can never be negative. External accounts are the system boundary. World and platform accounts are created on first use per currency; a project's five accounts are created together with its treasury (ledger::open_treasury) and never deleted. Accounts are unique per (owner_type, project_id, kind, currency).

Transactions

A transaction has a kind, a unique idempotency key, an optional project, a public description (which never contains payment references), private metadata, the actor, the time it happened, an optional reverses_transaction_id, and one or more entries in order.

KindEntries (debit → credit)Idempotency key
contribution_confirmedper item: world/contributions → project/available or platform/infrastructure_treasurycontribution:{id}:confirmed
payment_feeper item, pro rata: target account → world/payment_fees; with fee_bearer = infrastructure: platform/infrastructure_treasury → world/payment_fees, falling back to the funded accounts if the treasury cannot cover itcontribution:{id}:fee
contribution_refundedper item: target account → world/refunds (continuing through the dispute order if the target was drained after the request)contribution:{id}:refund:{provider_refund_ref}
contribution_disputeddispute order (below) → world/refunds; a won dispute reverses each stepcontribution:{id}:dispute:{ref}, won: …:dispute:{ref}:won
cycle_settlementevery movement of one cycle, one entry per non-zero linecycle:{project}:{period_start}
reward_fundedproject/flexible_pool → project/obligationsreward:{id}:funded
reward_cancelledproject/obligations → project/flexible_poolreward:{id}:cancelled
obligation_releasedproject/obligations → project/reserve or project/flexible_poolobligation:{id}:released, repeated: …:released:{n}
obligation_reinstatedproject/reserve or project/flexible_pool → project/obligationsobligation:{id}:reinstated, repeated: …:reinstated:{n}
payout_executedproject/obligations → project/paid_outpayout:{id}:executed
payout_reversedproject/paid_out → project/obligationspayout:{id}:reversed
infrastructure_expenseplatform/infrastructure_treasury → platform/infrastructure_spentinfra_expense:{id}
adjustmentany internal account ↔ world/adjustmentsadjustment:{uuid} (per request; the HTTP Idempotency-Key makes the request itself idempotent)

Posting

ledger::post(conn, NewTransaction) (crates/commonlayer-funding/src/ledger.rs):

  1. Validates: key 1–300 characters, description 1–2000 characters, at least one entry, positive amounts, one currency, two different accounts per entry, account kinds that belong to their owners.
  2. Returns AlreadyPosted(id) if the key exists. Callers treat that as success.
  3. Locks every involved ledger_balances row in ascending account-id order, so concurrent postings never deadlock.
  4. Inserts the transaction (ON CONFLICT (idempotency_key) DO NOTHING; a concurrent poster that won the race also yields AlreadyPosted) and its entries. The entry trigger applies each entry and enforces the balance rules.

Posting happens inside the caller's database transaction, together with the state change it belongs to (contribution status, cycle status, obligation rows) and the audit entry. If any entry fails, everything rolls back.

Only this module inserts into ledger_accounts, ledger_transactions, ledger_entries and project_treasuries; a test scans the crate's sources to keep it that way.

Invariants

#InvariantEnforced or checked by
L1Σ of all balances per currency = 0by construction; checked by reconciliation (check_ledger)
L2Every internal balance ≥ 0entry trigger at write time (CL001 → funding.insufficient_funds)
L3ledger_balances equals the sum over ledger_entries for every accountbalances are written only by the trigger (guard CL005); checked by reconciliation
L4Σ internal balances = money held at the providerprovider reconciliation, only for providers that report balances (Stripe); not available for the simulated and bank-transfer providers
L5A project's obligations balance = Σ its open obligations (pending, held, scheduled, failed)subledger reconciliation
L6An idempotency key is applied at most onceUNIQUE (idempotency_key)

Further database guarantees: ledger tables are append-only (UPDATE, DELETE, TRUNCATE raise CL002); CHECK (amount_minor > 0); CHECK (debit_account_id <> credit_account_id); ledger_balances keeps balance = credits − debits as a CHECK.

Database roles. The API, worker and scheduler connect as a runtime role that owns nothing (ADR-0036). On the append-only ledger tables it may only INSERT; ledger_balances is written only by the entry trigger, a SECURITY DEFINER function, and the runtime role holds no TRUNCATE, TRIGGER, ALTER or ownership, so it cannot disable the triggers or drop the tables. clctl db check-role proves this by attempting each forbidden operation in a rolled-back transaction; the tests in crates/commonlayer-database/tests/it/roles.rs cover UPDATE, DELETE, TRUNCATE, ALTER, DISABLE TRIGGER, DROP, session_replication_role and CREATE on the ledger and audit tables.

What is still not protected. A PostgreSQL superuser, or whoever holds the owner (migration) credentials, can disable triggers and rewrite rows. Such a change is detected by reconciliation only if balances and entries become inconsistent, and is not detected if both are rewritten consistently. There is no hash chain over the ledger (the audit log has one, but its checkpoints live in the same database and are not exported). These are open items O1 and O2 of the threat model; keep the owner credentials out of the running deployment.

Contributions

pending ──session created──▶ processing ──verified provider event──▶ confirmed
   │                              │                                     ├──▶ partially_refunded ──▶ refunded
   └──▶ cancelled                 └──▶ failed                           └──▶ disputed
  • POST /api/v1/funding/checkout (with Idempotency-Key) creates the contribution and its items (which must sum to the gross amount; deferred trigger CL007) and a provider session.
  • The provider's webhook is verified over the raw body, stored with a unique (provider, event_id) and processed by funding.process_webhook under a row lock on the contribution. Duplicates, reordering and concurrent delivery are safe because every effect is keyed.
  • Confirmation posts contribution_confirmed and, if the provider reports a fee, payment_fee; it assigns the receipt number from funding_receipt_sequences (row-locked, gapless per calendar year) and sets refundable_until.
  • If the provider confirms a different amount, the received amount is booked and items are re-sized pro rata; a late success after failed/cancelled confirms; a late failure after confirmation is ignored (ADR-0031).

Settlement

plan_settlement(inputs) -> SettlementPlan in crates/commonlayer-funding/src/domain/settlement.rs is pure. Inputs: the period, the policy snapshot (slots summing to T), the balances A (available), R (reserve), F (flexible pool), and per maintainer slot the occupancy segments (assignment, payee, days) with each assignment's eligibility decision, plus vacant days.

  1. Sweep the flexible pool. With flex_unused_strategy = sweep_to_reserve, move F to the reserve (line flexible_swept). Approved rewards already moved to obligations when they were funded, so F is the unused part.

  2. Distributable amount. D = min(A, T). If D < T and reserve_draw_enabled, draw min(T − D, R) from the reserve into available (line reserve_drawn) and add it to D.

  3. Size the slots in priority order (ties keep declaration order). If D = T, each slot gets its amount. Otherwise pro_rata scales every slot by D/T with largest-remainder rounding, or priority fills slots in order.

  4. Maintainer slots. Split the slot's size across the segments by days (proration = daily, largest remainder) plus a vacancy part for days without an occupant. With proration = none the whole slot goes to the segment with the most days (the later one on a tie; the plan left this case open). Per segment, by the finalized eligibility state:

    Finalized stateLineMovement
    eligible, at_riskobligation_created, obligation pendingavailable → obligations
    under_review, or no finalized evaluationobligation_held, obligation heldavailable → obligations
    ineligible, temporarily_suspendedforfeited_ineligible, no obligationavailable → reserve or flexible_pool (ineligible_strategy)
    vacancy partvacantavailable → reserve or flexible_pool (vacancy_strategy)
  5. Other slots. Flexible pool slot: available → flexible_pool (flexible_reserved). Infrastructure: available → platform/infrastructure_treasury (infrastructure_allocated) plus an infrastructure_allocations row. Reserve slot: available → reserve (reserve_allocated).

  6. Surplus. If available remains and the reserve is below reserve_target_months × T, move up to the difference to the reserve (surplus_swept); the rest stays in available.

The plan replays its own movements over the opening balances and fails if any bucket would go negative or the replay disagrees with the computed closing balances. Coverage in the cycle summary is A × 10000 / T before any draw; shortfall = max(T − A, 0).

The service then, in one database transaction under the project's finance advisory lock: writes funding_cycle_lines (each with its explanation and the evaluation it derives from), creates the obligations, posts one cycle_settlement transaction whose entries are the non-zero lines in order, marks the cycle settled, writes the audit entry, and publishes funding.cycle.settled plus funding.shortfall (whenever coverage is below the target, even if the reserve covered it) or funding.target.reached.

When settlement runs. settle_after = period end + funding.settlement_delay_days (default 3). A due cycle waits until MaintainerDirectory::eligibility_finalized reports the month finalized. An evaluation whose inputs could not be loaded is finalized as under_review after a grace period, so a settlement cannot be blocked forever; it then holds that maintainer's share. A cycle whose settlement fails stays open and is retried by the next run.

Worked examples (test cases)

Default policy, T = 50000 (minor units, $500.00).

#SituationResult
1A = 50000, both eligibleobligations 20000 + 15000; flexible 10000; infrastructure 5000; A = 0; coverage 10000 bps
2As 1, secondary ineligible, ineligible_strategy = reserve15000 → reserve, line forfeited_ineligible with the evaluation id; no obligation for them
3A = 30000, R = 0pro rata 12000 / 9000 / 6000 flexible / 3000 infrastructure; shortfall 20000
4A = 30000, R = 50000, draw enableddraw 20000; full allocation; R = 30000
5A = 50000; primary X days 1–10 of 30, then YX 6667, Y 13333 (both eligible)
6A = 80000, R = 0, reserve target 3 monthsfull allocation; surplus 30000 → reserve
7Primary slot vacant all month20000 → reserve, line vacant
8Secondary under_review15000 → obligations as held
9Treasury A = 1 000 000; primary steps down, replacement approvedno ledger transaction; all balances unchanged; the next settlement pays the replacement

Obligations

An obligation is a named debt of a project to a user for one cycle line or one reward.

pending ──planned in a run──▶ scheduled ──provider paid──▶ paid
held ──finalized eligible or appeal upheld──▶ pending
held ──finalized ineligible──▶ forfeited   (obligation_released)
scheduled ──provider failed──▶ pending (payout failed)
reward obligation ──cancelled──▶ cancelled (reward_cancelled)
  • funding.on_eligibility_changed subscribes to maintenance.eligibility.decided (finalized) and maintenance.eligibility.changed. A held obligation becomes pending or is released (obligation_released to the account the policy names; recorded in released_to).
  • An appeal upheld after a forfeiture creates the obligation from the cycle line and posts obligation_reinstated from the account that received the money, up to what that account still holds. A partial reinstatement reduces the obligation to what was recovered and records shortfall_minor, surfaced to administrators rather than hidden.
  • Each cycle line and each reward has at most one obligation (unique indexes).

Payout runs

  • POST /api/v1/admin/payout-runs (platform.payouts.initiate, Idempotency-Key) plans a run in mode dry_run, simulated or live. A dry run posts nothing and lists every obligation with the reason it would be skipped: no_verified_destination, tax_status_missing, below_minimum (payments.payouts.minimum_minor, default 1000) or held.
  • A non-dry run must be approved by a different platform admin (platform.payouts.approve, interactive session only; database CHECK).
  • Execution evaluates every gate and refuses with payouts.not_enabled listing the failed ones: payments.payouts.mode (equals the run's mode), provider.payouts_capability, payments.legal_entity, payments.tax.handling_confirmed, reconciliation.clean_and_recent (latest run of each kind clean and < 24 h old), run.second_approval, and for simulated runs environment.non_production.
  • The payee's destination is fixed when the run is planned (payouts.destination_ref, shown to the approver as destination_hint); execution pays only there. Changes of a maintainer's destination are audited with a SHA-256 and the last 4 characters.
  • Per payout: mark submitted, call the provider with the payout's provider idempotency key payout:<obligation>:<attempt>, which stays the same for every retry of an attempt; on success post payout_executed and mark the obligation paid; on a definitive rejection mark the payout failed, return the obligation to pending and record the reason (the next attempt gets a new key); on an ambiguous outcome (timeout, connection reset, unreadable answer, HTTP 5xx, 409, 429) mark the payout unknown and leave the obligation scheduled, so no later run can plan it again.
  • An unknown payout is resolved by an administrator in an interactive session after checking the provider (POST /api/v1/admin/payouts/{id}/resolve): paid with the provider reference posts payout_executed once; failed returns the obligation to pending. commonlayer_payouts_total{outcome="unknown"} drives the alert PayoutOutcomeUnknown (ADR-0041).
  • A provider reversal posts payout_reversed (POST /api/v1/admin/payouts/{id}/reverse or the provider's transfer.reversed event).
  • Approving a run requires a person in an interactive session, never a token or a service account (ADR-0035).
  • Payee readiness (verified destination, tax status) comes from the maintenance domain through MaintainerDirectory::payee_readiness; funding never reads maintainer tables.

Community rewards

required_approvals = 1 up to funding.reward_single_approval_max_minor (default 10000, a runtime setting), otherwise 2, one of which must be a project or platform admin. The proposer never rewards themselves; an approver is never the recipient nor the proposer (also a database trigger, CL006), whatever the number of required approvals; one contribution carries at most one open or funded reward (unique index); nobody decides twice. The final approval posts reward_funded from the flexible pool; if the pool is short the database refuses the posting and the service answers funding.insufficient_flexible_pool. Cancelling a funded reward that is not in a payout posts reward_cancelled. Approvers are people, never service accounts; a second open reward for the same contribution answers 409 funding.reward_exists (ADR-0045).

Refunds

  • Requested by the contributor (or an organization member with org.contributions.manage, or a platform admin with platform.payments.reconcile), only for confirmed or partially_refunded contributions, only before refundable_until (funding.refund_window_days, default 14), only up to gross − refunded − disputed, and only if each target account still holds its pro-rata share after the holds of refunds already pending. Otherwise funding.refund_not_available.

  • Refund holds (funding_refund_holds, security review MEDIUM-4): the request records a hold for each target account under the finance advisory lock, in the same transaction as the availability check. A hold is released when the provider confirms the refund (the posting then takes the money) or when the provider request fails; a hold whose provider event arrives before the request stored the refund reference lapses after 7 days instead.

  • A provider refund, dispute or won-dispute reference is applied once (funding_provider_withdrawals), even when the waterfall posts nothing, so a second event for the same refund (refund.created and refund.updated) changes nothing.

  • The ledger posts contribution_refunded when the provider confirms. If a target account was drained between request and confirmation, the remainder follows the dispute order instead of failing.

  • Refundable balance of a project = min(available, Σ unrefunded in-window contributions), published with the balances.

  • Subscription payments (security review HIGH-3): Stripe books a subscription payment under the invoice, while refund and dispute events name a PaymentIntent (pi_…) or charge (ch_…). The invoice_payment.paid event links them (funding_payment_aliases); an unmatched pi_/ch_ reference in a refund or dispute is resolved on demand through Stripe's GET /v1/invoice_payments list. A platform refund of such a payment is sent to Stripe with its PaymentIntent. Fees of subscription payments come from the invoice payment's PaymentIntent. Tested against a mock built from the Stripe reference; the invoice_payment.paid event must be enabled on the webhook endpoint for the event path.

Disputes

A provider dispute takes money whether or not the project still has it. contribution_disputed takes the amount in this order, one entry per step: available, reserve, flexible_pool, platform/infrastructure_treasury. Reaching the infrastructure treasury is an administrative alert. If no account can cover the full amount, only the covered part is posted and uncovered_minor is recorded with an audit entry of outcome failure; the ledger never overdraws. A won dispute posts the reverse movements with reverses_transaction_id set.

Reconciliation

funding.reconcile runs hourly (and on demand through POST /api/v1/admin/reconciliation/run) and records one reconciliation_runs row per check:

CheckCompares
ledgerevery stored balance against the sum of its entries; the per-currency sum of all balances against zero; no internal balance below zero
subledgereach project's obligations account against its open obligations; confirmed contributions against contribution:{id}:confirmed transactions and vice versa; paid and reversed payouts against their ledger transactions
providerpayment webhooks still received or failed after 15 minutes; with Stripe, Σ internal balances per currency against the provider's reported balance

A mismatch sets commonlayer_ledger_reconciliation_mismatches{check}, writes an audit entry with outcome failure, and blocks payout execution through the reconciliation gate. service::verify_ledger runs the ledger and subledger checks without recording them; it backs clctl ledger verify.

Runtime settings that change policy

These values can be changed by a platform administrator while the platform runs, within bounds, audited and versioned (ADR-0038). They never move money by themselves; they change what the next decision or computation uses.

SettingDefaultEffect
funding.surplus_bps, funding.fully_funded_bps, funding.partially_funded_bps12500, 10000, 5000funding-state thresholds (must stay ordered)
funding.underfunded_notice_cycles2consecutive underfunded cycles before the lifecycle becomes underfunded
funding.reward_single_approval_max_minor10000largest reward one approval can fund
funding.min_infrastructure_bps1000minimum infrastructure share of an allocation policy, checked when a policy is activated
funding.refund_window_days14refund window after confirmation, for contributions confirmed afterwards

Configuration that stays deployment configuration: the payment provider, fee bearer, payout mode, legal entity, tax handling, payout minimum, settlement delay, reserve runway.

Known limitations

  • Stripe has been exercised only against fixtures and a mock server; live payouts through Stripe Connect only against a mock. The invoice_payments list filters were taken from the object reference.
  • The provider balance comparison (L4) is only possible with Stripe.
  • Ledger and audit tamper resistance against the owner role or a superuser (O1, O2): no ledger hash chain, no external copy of checkpoints.
  • The API process holds the payment provider's secret key, because checkout, refunds and subscription changes are synchronous provider calls (threat model O7).
  • The payment_methods table is not written by any adapter yet; cycle status failed, reward status approved and payout-run status failed are not used.
  • Campaign completion is not automated.