Rail¶
A rail is the smallest indivisible money-movement primitive — one "thing the institution does that posts to the ledger". Two shapes:
- TwoLegRail — posts a debit + a credit pair atomically.
Declares
source_roleanddestination_role; every firing produces two Transaction rows that net to zero per the Conservation invariant. - SingleLegRail — posts a single leg. The other side comes
from elsewhere — either bundled into a multi-leg
Transfer Template, aggregated into a
parent firing of an
aggregatingrail, orExternalForcePosted(the institution's view doesn't include the offsetting side at all, like a Fed-side credit on an inbound wire).
Every Rail has:
name— the rail's identifier. Under Z.B (2026-05-15), the rail's name IS the type identifier; posted Transactions carry the rail's name in theirrail_namecolumn to bind back to it (e.g.CustomerOutboundACH,MerchantPayoutWire).posted_requirements— optional list of metadata keys that MUST be populated on the Transaction (card_brand,cashier, etc). L1 surfaces violations as posted-requirements drift.max_pending_age/max_unbundled_age— optional aging caps the Stuck Pending / Stuck Unbundled matviews use to surface legs that took too long to post or to bundle.aggregating— flag marking a rail as a bundler (an aggregating settlement rail, for example, picks up many sale-leg firings and emits one net-settled credit / debit pair).
Optional: typical amount range (AB.5)¶
A rail can declare amount_typical_range: [min, max] to express
"every firing on this rail is normally between these two dollar
magnitudes". This is a soft per-firing bound — not a hard
constraint enforced at write time. Two ways it changes behavior:
-
Test-data generator (today). Both the baseline emitter (
_baseline_amount_sample) and the planted-scenario emitter (_plant_amount_for_rail/_cap_breach_amount) sample amounts log-uniformly within the declared range. So if a rail declaresamount_typical_range: [5, 500], every demo firing on that rail lands between $5 and $500 with low-end clustering — the natural shape of retail card sales, not the heavy-tailed default the per-kind lognormal would produce. Plants size to the range midpoint so they look like ordinary firings (just at the boundary that triggers the SHOULD-constraint). Cap-breach plants on rails that also carry aLimitScheduleclamp torange.max × 3so the breach amount stays in a realistic ballpark relative to the rail's typical volume. -
Runtime SHOULD-constraint (follow-on). A future
<prefix>_magnitude_anomalymatview will surface posted Transactions that fall outside the declared range. Deferred from AB.5 per the gap doc's "generator-only first cut" — lands when an integrator asks for runtime anomaly surfacing.
Validator rules (V1a-c):
- V1a —
minMUST be strictly less thanmax(degenerate single-point ranges rejected; if you want a fixed amount, write the seed directly). - V1b — both
minandmaxMUST be> 0. The bound is onabs(amount), so signed and zero values have no meaning. - V1c — forbidden on rails with
aggregating: true. Aggregator amounts derive from bundled children, so a per-firing band on the aggregator is fuzzy — set the range on the child rails instead.
Sampling shape. The generator samples log-uniformly:
amount = exp(uniform(log(min), log(max))). This reproduces the
low-end clustering financial flows naturally show (most retail card
swipes are small; large ones are rare). For tightly-peaked flows
(e.g., a payroll rail where 90% of firings cluster within ±10% of
a single value) a follow-on amount_distribution: {median,
sigma_log} shape is planned — flagged in the L2 grammar evolution
queue, not landed yet.
The Rail-to-Transaction binding is direct: a posted Transaction's
rail_namecolumn matches exactly one declaredRail.name(the validator's R10 / U3 rules enforce uniqueness + resolution at L2 load time, so the binding can never be ambiguous).
Optional: typical firing-count range (AF)¶
Where amount_typical_range bounds how much each firing moves,
firings_typical_per_period bounds how many times the rail fires
per period. The two compose: realistic per-firing amounts × realistic
per-period counts = a realistic per-period aggregate — the
daily/monthly top-line operators scan first when judging whether a
demo's numbers are plausible. A $50 typical card sale fired 50,000
times a day implies $2.5M/day in card volume; for a small community
bank that's an order of magnitude too large no matter how realistic
the per-swipe amount is.
Two accepted YAML shapes:
# Compact — period defaults to business_day.
- name: MerchantCardSale
firings_typical_per_period: [50, 500] # 50-500 swipes per business day
# Full — explicit period (business_day | pay_period | week | month).
- name: CustomerFeeMonthlySettlement_child
firings_typical_per_period:
period: month
range: [80, 120]
- Test-data generator (today). When set, the baseline emitter
(
_pick_firings_count) samples a per-period count uniform-randomly from the band and scales by the number of periods in the window (count-per-period × periods = total firings over the window). When absent, it falls back to the per-kind firing-count heuristic — without consuming any RNG state, so pre-AF L2 instances stay byte-identical to their locked seeds. The per-day distribution is the generator's existing Poisson spread, so the declared band shows up as the aggregate-per-period the operator intended. Composes withamount_typical_range— count, then per-firing amount, fully independent. - Runtime SHOULD-constraint (follow-on). A future
<prefix>_volume_anomalymatview will surface periods whose actual firing count falls outside the declared band (early-warning surveillance: "today's transfer count is 10× yesterday — what changed?"). Deferred per the gap doc's "generator-only first cut".
Validator rules (W1a-c):
- W1a —
minMUST be<= max. Equal endpoints ARE allowed ([1, 1]= "exactly one per period" — a legitimate fixed count, unlike AB.5's V1a which rejects degenerate amount ranges). - W1b — both
minandmaxMUST be>= 0. Zero is allowed (a rail that typically fires zero times in some periods). Negative counts are rejected. - W1c — forbidden on rails with
aggregating: true. An aggregating rail'scadencealready governs its firing frequency (one firing per cadence-period), so a count band would conflict. Set the band on the child rails the aggregator bundles instead.
firings_typical_per_period is also valid on a TransferTemplate
(W1a-b only — templates aren't aggregating rails). Declaring it is the
explicit opt-in that drives a coupled unit firing: every firing emits
all the template's leg_rails together as one balanced Transfer, at the
declared per-period count, and those leg_rails do NOT also fire
independently in the per-rail loop (that would double-emit + uncouple the
legs, ignoring the band and tripping false drift — Gap J). Use it for a
genuinely atomic multi-leg flow — e.g. a card-load = cardholder-credit +
clearing-debit pair that is ONE event with two legs.
Coupling is gated on this declaration ALONE — never inferred from
chain-parenthood (Gap J follow-up). A template referenced as a Chain
parent also unit-fires (once per business day, so the chain overlay
has a parent firing to thread children onto — AG.1), but if it does NOT
declare firings_typical_per_period its leg_rails ALSO keep firing
independently in the per-rail loop. That's deliberate: chain-parenthood is
a linkage property, not a claim that the legs are one balanced event. A
settlement-cycle template whose legs are independent activities (high-
volume sales vs. occasional adjustments, each at its own rail-level band)
must keep those distinct per-leg volumes — collapsing them into one shared
per-firing count is exactly the v11.9.2 regression v11.9.3 fixes.
Period-to-window conversion uses standard banking ratios: 5 business days/week, 10/pay-period (bi-weekly), 21/month. A window shorter than one period still emits one period's worth of firings.