Cambium: one language for business logic

Business rules usually live in five places: code, spreadsheets, settings screens, report formulas and people's heads. Cambium puts them in one place, where they can be read, checked and replayed.

What the language guarantees

  • Programs always finish
  • No floating point: money is exact 128-bit arithmetic, and overflow is an error, never a silent rounding
  • Allocation is exact: the parts always sum to the whole
  • The same inputs always give the same result
  • A permission is checked before a command's body runs
One Cambium description, many resultsOne Cambium descriptionrules · states · rights · settingsChecksbefore launchRights checkedbefore commandsSettings pagewith limitsState diagramand work boardTyped APIand clientsJournal and«why this number»
One description gives the checks, the rights, the settings page, the state diagram, the typed API and the journal.

Every example below is real Cambium and passes the language's checker as written.

Example 1: a package and its settings

A package names itself, its version and the packages it relies on:

[package]
name = "partner_payouts"
version = "1.4.0"
kind = "package"

[requires]
ledger = "1"
sales = "1"

Then it declares what an organisation may tune — each setting with its type, range and default:

enum PayoutMode { Manual, Automatic }

config payout {
    rate: ratio      { range: 0.01..=0.3, step: 0.001, default: 0.05 },
    wait: duration   { range: 1d..=30d, default: 3d },
    minimum: ratio   { range: 0..=1000, default: 50 },
    mode: PayoutMode { default: Manual },
} ensure mode is Manual or wait >= 7d

pub config referral {
    inviter_points: ratio  { range: 0..=10000, default: 500 },
    friend_discount: ratio { range: 0..=0.5, default: 0.1 },
    window: duration       { default: 180d },
}

From these lines the platform builds the settings page with its limits, the right to edit it, input checks and a change log with author and date. The last line is a rule across fields: automatic payouts must wait at least a week.

Example 2: B2C loyalty — cashback, bonuses, limits

Cashback by receipt size, a bonus for reviews with a cap of five a month, and a limit on paying with points:

config redeem {
    share_max: ratio { range: 0..=1, default: 0.3 },
}

/// Cashback by receipt size: the first true line wins.
rule tiered_cashback on sales::receipt.closed as receipt {
    params {
        bonus_from: ratio { default: 200 },
        bonus_points: ratio { default: 500 },
        high_from: ratio { default: 100 },
        high_rate: ratio { range: 0..=0.5, default: 0.1 },
        mid_from: ratio { default: 50 },
        mid_rate: ratio { default: 0.05 },
        min_total: ratio { default: 15 },
        base_rate: ratio { default: 0.03 },
    }
    when receipt.total >= params.bonus_from => points::bonus(receipt.member, params.bonus_points, lot: LotSpec {
        pool: Base,
        expires: None,
    });
    when receipt.total > params.high_from => points::rate(receipt, params.high_rate);
    when receipt.total >= params.mid_from => points::rate(receipt, params.mid_rate);
    when receipt.total < params.min_total => skip;
    else => points::rate(receipt, params.base_rate);
}

/// A verified review with a photo earns 50 points, at most 5 times a month.
rule review_bonus on reviews::published as published where published.verified and published.has_photo {
    params {
        points: ratio { default: 50 },
        max_per_month: u64 { default: 5 },
    }
    progress per published.member within calendar_month { n: u64 }
    when progress.n < params.max_per_month => points::bonus(published.member, params.points, lot: LotSpec {
        pool: Promo,
        expires: None,
    }), progress.n.inc(1);
    else => skip;
}

/// Points may pay for at most 30 % of a receipt.
policy points::redeem(r: sales::Receipt, amount: ratio) -> bool {
    amount <= r.total * config.redeem.share_max
}

Lines are checked top to bottom and the first true one wins. The monthly cap is counted per member and starts again each calendar month. Every number is a setting that marketing changes without a programmer.

Example 3: B2B terms — volume tiers and credit

A quarterly volume scale paid as a credit note, a credit limit that grows after a year of on-time payments, and a block on offers while a dealer is over the limit:

query quarter_volume(d: Id<b2b::Dealer>, q: period) -> ratio {
    b2b::invoice.filter(|i| i.dealer == d and i.closed_at in q).sum(|i| i.total)
}

/// Volume scale: from 500 000 a quarter 4 %, from 1 000 000 6 %, paid as a credit note.
rule retro_scale every quarter for d in b2b::dealer {
    params {
        tier1_from: ratio { default: 500_000 },
        tier1: ratio { range: 0..=0.2, default: 0.04 },
        tier2_from: ratio { default: 1_000_000 },
        tier2: ratio { range: 0..=0.2, default: 0.06 },
    }
    let volume = quarter_volume(d.id, period.previous_quarter());
    when volume >= params.tier2_from => ledger::credit_note(d.account, volume * params.tier2, key: (RetroScale, d.id, period.previous_quarter()));
    when volume >= params.tier1_from => ledger::credit_note(d.account, volume * params.tier1, key: (RetroScale, d.id, period.previous_quarter()));
}

/// A full year of NET 30 invoices paid on time raises the credit limit by 25 %.
rule flawless_net30 every year for d in b2b::dealer {
    params {
        raise: ratio { range: 0..=1, default: 0.25 },
    }
    when b2b::invoice.filter(|i| i.dealer == d.id and i.due_at in period.previous_year()).all(|i| i.paid_at is Some(p) and p <= i.due_at)
        => b2b::dealer.set_credit_limit(d.id, d.credit_limit * (1 + params.raise), key: (CreditRaise, d.id, period.previous_year()));
}

/// Over the credit limit: no discounts and no bonuses until the balance is back.
policy offers_allowed(d: b2b::Dealer) -> bool {
    b2b::balance(d.id) <= d.credit_limit
}

Each credit note carries a key — the dealer and the quarter — so a repeated run never pays twice.

Example 4: a bonus pool

A decision table sets each partner's weight by tier and volume; the first matching row wins. A query computes the pool: paid orders in the period times the share that was in force at the start of that period.

/// A partner's weight in the pool: tier and personal volume for the period.
decision weight(tier: Tier, volume: ratio) -> u8 hit first {
    | tier | volume | => weight |
    | crm::Tier.Platinum | >= 10_000 | => 3 |
    | crm::Tier.Gold | >= 5_000 | => 2 |
    | crm::Tier.Gold | _ | => 1 |
    | _ | >= 1_000 | => 1 |
    | _ | _ | => 0 |
}

/// The pool: paid turnover of the period times the share in force when the period began.
pub query pool_total(p: period) -> ratio = (sales::Order where status is Paid and paid_at in p sum(total)) * config.pool.as_of(p.start).share;

The command that pays the pool out requires a permission, runs at most once per period and splits the pool to the cent — the parts always add up to the pool:

/// Pays the pool out to the cent: the parts always add up to the pool, in one batch of transfers.
pub cmd distribute(p: period) -> Result<Id<ledger::Batch>, ledger::TransferError> permission bonus.distribute idempotent by p {
    let members = participants(p);
    let parts = allocate(pool_total(p), members.map(|m| m.w), 2);
    ledger::batch(members.zip(parts).map(|(m, amt)| ledger::leg(ledger::Party.Kind {
        holder: "bonus_pool",
        kind: "pool",
    }, ledger::Party.Account { account: m.bonus_account }, ledger::amount(amt, ledger::Asset.Points {
        pool: "bonus",
    }), bonus.pool { period: p, member: m.id })))
}

Example 5: how a status may change

A status machine lists the allowed moves between states. For each move it says how the move may happen: on an event, or by a right — a person moves it by hand, for example with a button in the interface. The choice is made per move. A condition (ensure) can guard a move of either kind.

Order state machine: some moves happen on an event, others are made by a person with the rightDraftPlacedPaidShippedCancelledorder.cancelRefundedorder.refundorder.placepayment.receivedorder.shipon an eventby a right
Order states. Solid arrows happen on an event; dashed arrows are moved by a person with the right.
machine Order.status {
    Draft  -> Placed    permission order.place
                        ensure lines.len() > 0 else (err.empty { order: id });
    Placed -> Paid      on payments::payment.received as p
                        where p.order == id;
    Paid   -> Shipped   permission order.ship { emit order.shipped { order: id }; }
    Paid   -> Refunded  permission order.refund;
    Placed -> Cancelled permission order.cancel;
}
  • Draft → Placed — by hand, with the place right, and only if the order has lines; otherwise the move is refused with a reason naming the order
  • Placed → Paid — on an event: the payment for this order is received
  • Paid → Shipped — by hand, with the ship right; the move also announces the shipment to everyone who listens
  • Paid → Refunded and Placed → Cancelled — by hand, each with its own right

The same works for any entity. Here _ means «from any state»: a member can be banned or suspended whatever their status, but only someone with the right can bring a suspended member back:

machine Member.status {
    _         -> Banned    permission admin.ban;      // from any state
    _         -> Suspended permission admin.suspend;
    Suspended -> Active    permission admin.resume;
}

A manual move names only the target state. The machine finds the allowed move from the current state, checks the right and the condition, and only then changes the status:

pub cmd ship(o: Id<Order>) -> Result<(), err.empty> permission order.ship idempotent by o {
    Order.move(o, Shipped)?;          // manual move: target state; the machine finds the edge
    Ok(())
}

What is not declared cannot happen at all, whatever rights a user holds: no right turns a shipped order back into a draft, because that move does not exist. Writing the status field directly is refused as well — every change goes through the machine. Every move also raises a status-changed event with the old and the new state, for reports, notifications and audit.

Example 6: permissions and session trust

Rights are declared once per package, in groups. Each right also states the lowest session trust it needs, from anonymous up to a qualified signature:

/// Rights in groups; each names the lowest session trust it needs.
permissions {
    payout level strong {
        read level identified,
        edit,
        approve implies read,
    },
    all_regions,
    director level proven,
}

config payout {
    approve_limit: Amount<usd> { default: 10_000 },
}

entity Payout {
    region: Region,
    status: PayoutStatus,
    amount: Amount<usd> write permission payout.edit,
    note: string,
}

/// Approval is a manual edge: a right and a condition; above the limit the director must join.
machine Payout.status {
    Draft -> Approved permission if amount < config.payout.approve_limit { payout.approve } else { payout.approve and director }
                      ensure amount > 0 usd and note != "" else (err.payout.state);
}

/// Row policy: a user sees only the payouts of their own regions.
policy payout.read(p: Payout, actor: Actor) -> bool {
    actor.regions.contains(p.region) or actor.has(all_regions)
}

pub query payouts_due() permission payout.read = Payout where status is Draft sum(amount);
  • Reading payouts needs an identified session; approving needs a second factor; the director right needs identity proven with a document
  • Whoever may approve may also read
  • The amount can be changed only with the edit right
  • Above the configured limit, approval needs the director as well
  • The row policy limits every query to the user's regions: the total above has no filter of its own and still returns only what this user may see
  • Approval is a manual move: a person with the right moves the payout to Approved, and only if the amount is positive and a note explains it. The same move could instead be bound to an event — that is a choice made per move

Session trust is a separate axis from rights. Lacking the right is a final «no». Lacking trust is not: the answer comes with a ready form — «confirm with a stronger factor» — in the same response. An organisation may raise the required level, never lower it.

Where Cambium is used

  • Partner-network volume, payouts and bonus pools
  • Loyalty programmes, B2B terms and credit
  • Account roll-ups, bank files and licence expiry
  • Order workflows and row-level access
  • Warehouse processes

Short programs are pleasant, but line counts are the weakest argument. The real difference is the guarantees: exact money, programs that finish, and the same answer every time.

Next step

See it on your own business

Book a demo: we show the platform on your processes and agree where your data will live — in our cloud or on your own nodes.