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
| Area | State | Evidence |
|---|---|---|
| Posting API, invariants, triggers | Implemented | tests/it/ledger.rs; database tests in crates/commonlayer-database/tests/it/invariants.rs |
| Settlement | Implemented as a pure function plus persistence | the nine worked examples as unit tests; two property tests (1 500 cases each); tests/it/cycles.rs |
| Contributions, webhooks, refunds, disputes | Implemented | tests/it/contributions.rs, webhooks.rs, refunds.rs (duplicate, reordered and 12 concurrent deliveries of one event) |
| Obligations, payout runs, rewards | Implemented | tests/it/obligations.rs, payouts.rs, rewards.rs (6 concurrent approvals on one pool: exactly the affordable 2 are funded) |
Reconciliation, verify_ledger | Implemented | tests/it/reconciliation.rs; every database test ends with a ledger soundness check |
| Zero-sum property | Tested | tests/it/properties.rs: random sequences of 24 operations with real fees; invariants checked after every step |
| Fifteen-step scenario of the product brief | Tested | tests/it/scenario.rs |
| Review fixes (HIGH-1, -3, -4, -5, MEDIUM-4, -6, LOW-1 to -4) | Implemented | tests/it/hardening.rs, stripe.rs, wp70.rs (docs/status/WP-70b.md names each test) |
| Stripe, including invoice-payment linking for subscription refunds and disputes | Implemented; not run against a live account | tests/it/stripe.rs uses fixtures built from the API reference and a mock server |
| Live payouts (Stripe Connect) | Implemented; only exercised against a mock | tests/it/payouts.rs and hardening.rs use the simulated provider |
| Composed system | Tested | acceptance 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 verify | Implemented (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 operation | None | — |
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.
| Owner | Kind | Class | Meaning |
|---|---|---|---|
| world | contributions | external | Source of all confirmed money in; its balance is minus the total ever received |
| world | payment_fees | external | Provider fees |
| world | refunds | external | Money returned to contributors (refunds and disputes) |
| world | adjustments | external | Counter-account for audited manual corrections |
| platform | infrastructure_treasury | internal | Shared infrastructure funds |
| platform | infrastructure_spent | external | Infrastructure money paid to vendors |
| project | available | internal | Unallocated project funds |
| project | flexible_pool | internal | Community rewards and emergency work |
| project | reserve | internal | Continuity reserve |
| project | obligations | internal | Owed to named payees, not yet paid |
| project | paid_out | external | Cumulative 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.
| Kind | Entries (debit → credit) | Idempotency key |
|---|---|---|
contribution_confirmed | per item: world/contributions → project/available or platform/infrastructure_treasury | contribution:{id}:confirmed |
payment_fee | per 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 it | contribution:{id}:fee |
contribution_refunded | per 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_disputed | dispute order (below) → world/refunds; a won dispute reverses each step | contribution:{id}:dispute:{ref}, won: …:dispute:{ref}:won |
cycle_settlement | every movement of one cycle, one entry per non-zero line | cycle:{project}:{period_start} |
reward_funded | project/flexible_pool → project/obligations | reward:{id}:funded |
reward_cancelled | project/obligations → project/flexible_pool | reward:{id}:cancelled |
obligation_released | project/obligations → project/reserve or project/flexible_pool | obligation:{id}:released, repeated: …:released:{n} |
obligation_reinstated | project/reserve or project/flexible_pool → project/obligations | obligation:{id}:reinstated, repeated: …:reinstated:{n} |
payout_executed | project/obligations → project/paid_out | payout:{id}:executed |
payout_reversed | project/paid_out → project/obligations | payout:{id}:reversed |
infrastructure_expense | platform/infrastructure_treasury → platform/infrastructure_spent | infra_expense:{id} |
adjustment | any internal account ↔ world/adjustments | adjustment:{uuid} (per request; the HTTP Idempotency-Key makes the request itself idempotent) |
Posting
ledger::post(conn, NewTransaction) (crates/commonlayer-funding/src/ledger.rs):
- 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.
- Returns
AlreadyPosted(id)if the key exists. Callers treat that as success. - Locks every involved
ledger_balancesrow in ascending account-id order, so concurrent postings never deadlock. - Inserts the transaction (
ON CONFLICT (idempotency_key) DO NOTHING; a concurrent poster that won the race also yieldsAlreadyPosted) 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
| # | Invariant | Enforced or checked by |
|---|---|---|
| L1 | Σ of all balances per currency = 0 | by construction; checked by reconciliation (check_ledger) |
| L2 | Every internal balance ≥ 0 | entry trigger at write time (CL001 → funding.insufficient_funds) |
| L3 | ledger_balances equals the sum over ledger_entries for every account | balances are written only by the trigger (guard CL005); checked by reconciliation |
| L4 | Σ internal balances = money held at the provider | provider reconciliation, only for providers that report balances (Stripe); not available for the simulated and bank-transfer providers |
| L5 | A project's obligations balance = Σ its open obligations (pending, held, scheduled, failed) | subledger reconciliation |
| L6 | An idempotency key is applied at most once | UNIQUE (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(withIdempotency-Key) creates the contribution and its items (which must sum to the gross amount; deferred triggerCL007) and a provider session.- The provider's webhook is verified over the raw body, stored with a unique
(provider, event_id)and processed byfunding.process_webhookunder a row lock on the contribution. Duplicates, reordering and concurrent delivery are safe because every effect is keyed. - Confirmation posts
contribution_confirmedand, if the provider reports a fee,payment_fee; it assigns the receipt number fromfunding_receipt_sequences(row-locked, gapless per calendar year) and setsrefundable_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/cancelledconfirms; 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.
-
Sweep the flexible pool. With
flex_unused_strategy = sweep_to_reserve, moveFto the reserve (lineflexible_swept). Approved rewards already moved toobligationswhen they were funded, soFis the unused part. -
Distributable amount.
D = min(A, T). IfD < Tandreserve_draw_enabled, drawmin(T − D, R)from the reserve into available (linereserve_drawn) and add it toD. -
Size the slots in priority order (ties keep declaration order). If
D = T, each slot gets its amount. Otherwisepro_ratascales every slot byD/Twith largest-remainder rounding, orpriorityfills slots in order. -
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. Withproration = nonethe 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 state Line Movement eligible,at_riskobligation_created, obligationpendingavailable → obligationsunder_review, or no finalized evaluationobligation_held, obligationheldavailable → obligationsineligible,temporarily_suspendedforfeited_ineligible, no obligationavailable → reserveorflexible_pool(ineligible_strategy)vacancy part vacantavailable → reserveorflexible_pool(vacancy_strategy) -
Other slots. Flexible pool slot:
available → flexible_pool(flexible_reserved). Infrastructure:available → platform/infrastructure_treasury(infrastructure_allocated) plus aninfrastructure_allocationsrow. Reserve slot:available → reserve(reserve_allocated). -
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 inavailable.
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).
| # | Situation | Result |
|---|---|---|
| 1 | A = 50000, both eligible | obligations 20000 + 15000; flexible 10000; infrastructure 5000; A = 0; coverage 10000 bps |
| 2 | As 1, secondary ineligible, ineligible_strategy = reserve | 15000 → reserve, line forfeited_ineligible with the evaluation id; no obligation for them |
| 3 | A = 30000, R = 0 | pro rata 12000 / 9000 / 6000 flexible / 3000 infrastructure; shortfall 20000 |
| 4 | A = 30000, R = 50000, draw enabled | draw 20000; full allocation; R = 30000 |
| 5 | A = 50000; primary X days 1–10 of 30, then Y | X 6667, Y 13333 (both eligible) |
| 6 | A = 80000, R = 0, reserve target 3 months | full allocation; surplus 30000 → reserve |
| 7 | Primary slot vacant all month | 20000 → reserve, line vacant |
| 8 | Secondary under_review | 15000 → obligations as held |
| 9 | Treasury A = 1 000 000; primary steps down, replacement approved | no 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_changedsubscribes tomaintenance.eligibility.decided(finalized) andmaintenance.eligibility.changed. A held obligation becomespendingor is released (obligation_releasedto the account the policy names; recorded inreleased_to).- An appeal upheld after a forfeiture creates the obligation from the cycle line and
posts
obligation_reinstatedfrom the account that received the money, up to what that account still holds. A partial reinstatement reduces the obligation to what was recovered and recordsshortfall_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 modedry_run,simulatedorlive. 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) orheld.- A non-dry run must be approved by a different platform admin
(
platform.payouts.approve, interactive session only; databaseCHECK). - Execution evaluates every gate and refuses with
payouts.not_enabledlisting 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 runsenvironment.non_production. - The payee's destination is fixed when the run is planned (
payouts.destination_ref, shown to the approver asdestination_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 keypayout:<obligation>:<attempt>, which stays the same for every retry of an attempt; on success postpayout_executedand mark the obligationpaid; on a definitive rejection mark the payoutfailed, return the obligation topendingand 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 payoutunknownand leave the obligationscheduled, 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):paidwith the provider reference postspayout_executedonce;failedreturns the obligation topending.commonlayer_payouts_total{outcome="unknown"}drives the alertPayoutOutcomeUnknown(ADR-0041). - A provider reversal posts
payout_reversed(POST /api/v1/admin/payouts/{id}/reverseor the provider'stransfer.reversedevent). - 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 withplatform.payments.reconcile), only forconfirmedorpartially_refundedcontributions, only beforerefundable_until(funding.refund_window_days, default 14), only up togross − refunded − disputed, and only if each target account still holds its pro-rata share after the holds of refunds already pending. Otherwisefunding.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.createdandrefund.updated) changes nothing. -
The ledger posts
contribution_refundedwhen 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_…). Theinvoice_payment.paidevent links them (funding_payment_aliases); an unmatchedpi_/ch_reference in a refund or dispute is resolved on demand through Stripe'sGET /v1/invoice_paymentslist. 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; theinvoice_payment.paidevent 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:
| Check | Compares |
|---|---|
ledger | every stored balance against the sum of its entries; the per-currency sum of all balances against zero; no internal balance below zero |
subledger | each 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 |
provider | payment 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.
| Setting | Default | Effect |
|---|---|---|
funding.surplus_bps, funding.fully_funded_bps, funding.partially_funded_bps | 12500, 10000, 5000 | funding-state thresholds (must stay ordered) |
funding.underfunded_notice_cycles | 2 | consecutive underfunded cycles before the lifecycle becomes underfunded |
funding.reward_single_approval_max_minor | 10000 | largest reward one approval can fund |
funding.min_infrastructure_bps | 1000 | minimum infrastructure share of an allocation policy, checked when a policy is activated |
funding.refund_window_days | 14 | refund 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_paymentslist 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_methodstable is not written by any adapter yet; cycle statusfailed, reward statusapprovedand payout-run statusfailedare not used. - Campaign completion is not automated.