Skip to content

How do I set typical amount ranges on a rail?

Customization walkthrough — Integrator / Trainer. Shaping the demo.

The story

A demo went sideways last week: the dashboard's Limit Breach sheet surfaced a "$1,247,329 daily ACH outflow" row, the auditor stopped the demo at sheet two and asked, "is that a real number?". It isn't — the per-kind lognormal default in _baseline_amount_sample happens to roll a high-tail draw occasionally, and the planted Outbound LimitBreachPlant was sized cap × 1.5 regardless of what the rail's normal volume looks like. The numbers are valid; they're just absurd for retail card sales force-posting through the MerchantPayableClearing GL.

You want every firing on your retail card-sale rail to land between $5 and $500 (typical low-end clustering — single coffees to high-end retail), and you want the planted scenarios to size to the same band so plants look like ordinary firings (just at the boundary that triggers the SHOULD-constraint).

This is the amount_typical_range feature: you declare a per-firing soft bound on the rail and both the baseline emitter and the planted-scenario emitter respect it without any schema migration or matview rewrite.

The question

"How do I make demo amounts on the MerchantCardSale rail land between $5 and $500 instead of the heavy-tailed lognormal default?"

Where to look

Three reference points:

  • Rail (concept) → Optional: typical amount range — the field semantics: the log-uniform sampler, the cap interaction and the V1a-c validator rules.
  • tests/l2/spec_example.yaml — the minimal fixture carries 3 ranged rails (ExternalRailInbound [50, 5000], ExternalRailOutbound [50, 10000], SubledgerCharge [1, 100]). Search for amount_typical_range to find them.
  • run/sasquatch_pr.yaml (or your own L2 yaml under run/) — the real-world example carries ranges on 6 representative rails, including MerchantCardSale [5, 500] and CustomerFeeAccrual [0.25, 25].

The change

In your run/<institution>.yaml, find the rail you want to bound and add amount_typical_range: [min, max]:

rails:
  # existing rails...
  - name: MerchantCardSale
    source_role: ExternalCardNetwork
    destination_role: MerchantPayableClearing
    metadata_keys: [merchant_id, settlement_period, card_network_ref, card_brand]
    amount_typical_range: [5, 500]
    description: |
      One merchant card-sale posting (card network force-posts the
      external debit leg; the internal ledger credits the MerchantPayableClearing
      GL; values cluster at the low end via log-uniform sampling).

Two notes on shape:

  • min and max are dollar amounts (not cents). They accept the same shape the rest of the L2 grammar uses for Money — strings ("5.00"), bare ints (5) or floats (5.00).
  • min MUST be strictly less than max (V1a). Both MUST be > 0 (V1b). The field is FORBIDDEN on rails with aggregating: true (V1c) — aggregator amounts derive from bundled children, so set the range on the child rails instead.

How to verify

Re-seed the demo, then refresh the matviews (data apply does NOT auto-refresh — the Limit Breach sheet reads a matview, so skip this and it shows the OLD amounts):

recon-gen data apply -c run/config.yaml --execute
recon-gen data refresh -c run/config.yaml --execute

The seed regenerates the demo Transactions with the log-uniform sampler honoring your declared range. Open the L1 dashboard and filter to rail_name = MerchantCardSale — every firing should land between $5 and $500, clustering at the low end.

See it live (the spec_example fixture's ranged rails render there).

If your rail also carries a LimitSchedule cap, the cap-breach plant amount is now max(min(cap × 1.5, range.max × 3), cap + 1). The realism clamp pulls the breach down toward range.max × 3; the cap + 1 floor guarantees it still EXCEEDS the cap (without that floor a high cap on a small-range rail could clamp BELOW the cap and the violation would vanish — a real bug we hit).

So a $5000 cap on a rail with amount_typical_range: [5, 500]: min(7500, 1500) = 1500 falls below the cap, the floor wins, and the plant breaches at $5001 — just over the cap instead of an absurd $7500. When the range is wide enough that range.max × 3 clears the cap, the clamp lands the breach realistically on its own.

What you should NOT do

  • Don't set amount_typical_range on an aggregating rail. Validator V1c rejects this at load time. Aggregator amounts derive from bundled children; set the range on the child rails instead.
  • Don't set min == max (V1a rejects). If you want a fixed amount, write a direct seed via TransferTemplatePlant / RailFiringPlant instead — those are sized explicitly.
  • Don't set negative or zero values (V1b rejects). The bound is on abs(amount); signed and zero values have no meaning.
  • Don't expect the validator to enforce the range at write time. The bound is a generator-shaping hint AND a future runtime SHOULD-constraint matview hook — not a hard CHECK constraint on the transactions table. Real-world data that falls outside the band will surface in a follow-on _magnitude_anomaly matview when that lands.