Domain Model — QuickSight Analysis Generator¶
Overall Goal¶
Help integrators generate AWS QuickSight dashboards that help non-technical financial users find and triage problems in their unique institution. This consists of a shared common library that wraps the QuickSight JSON and a series of example applications built on top that are easily customizable to the situation.
Audiences¶
Four audiences with different needs. Design decisions trace to one or more of them; features that serve none are out of scope.
- Business Analyst / Product Owner: customizes the apps onto a real institution.
- Describes the institution's structure and external relationships in L2 so the demo data and dashboards reflect their world.
- Trains the other audiences against a stable demo system that mirrors real-data deployments.
- Integration Engineer: wires the apps into a host system.
- Understands the two source tables (
Transaction,StoredBalance) that drive every app. - Writes ETL that populates them on a recurring schedule.
- Builds custom apps on the L1 primitives, or extends the shipped apps.
- Edits each behavior in one place (DRY); trusts the test suite to catch regressions; iterates fast (regenerate + redeploy in one command); reskins via theme presets.
- Non-technical Accountant: uses the dashboards day-to-day.
- Job is to find problems and route them to the team that fixes them.
- Strong accounting background, not a programmer; the dashboards are unfamiliar — plain-English labels, hint text, and Getting Started prose are load-bearing.
- Needs to recognize when something needs investigation, not how to fix the broken upstream system.
- Third-party Stakeholder: consumes the dashboards for compliance, metrics, or audit.
- Not the primary user. The system stays extensible to meet evolving requirements without disrupting the core experience.
Architecture Layers¶
The model is organized in three layers:
- LAYER 1 — Universal model: Money, accounts, transfers, transactions, balances, and the invariants they obey. Same for every institution. Shipped as library code. Integrators do not modify.
- LAYER 2 — Institutional model: Per-integrator description of this institution's account roles, transfer rails, business processes, and reconciliation expectations. Defined by the integrator as data (a YAML instance). The library reads it to scope LAYER 1 constraints to the institution's specifics, generate seed data, and render handbook prose.
- LAYER 3 — Applications: A fixed set of dashboard apps, each answering one question shape the L1 primitives and L2 instance can produce. The library ships multiple orthogonal apps; an institution gets every shipped app deployed against its single L2 instance, no code changes required. Integrators build custom apps on the L1 primitives when no shipped app covers their question.
LAYER 1 SHAPES are rigid (Conservation is Conservation); LAYER 1 SCOPES (which TransferTypes have ExpectedNet=0, which accounts have ExpectedEODBalance set, etc.) are filled in by LAYER 2. LAYER 2 itself is fully defined by the integrator — the library has no opinion beyond providing the LAYER 1 building blocks to express it. LAYER 3 is fixed by the library; institutions get the same app shapes regardless of L2 content.
Notation Conventions¶
- Type definition:
TypeName: (Field: Type, OptionalField?: Type)— both field names and types are PascalCase. A bare type name in a tuple is shorthand for a same-named field:(ID, Name?)≡(ID: ID, Name?: Name). - Type as set of values:
TypeName ⊇ {member, …}for open sets (the system uses at least these; more may exist);TypeName = {member, …}for closed sets (the universe is fixed). - Set filter:
TypeName(Field = value, …)denotes the subset ofTypeNameinstances where each named field equals the given value. The set name is the type name (no plural). - Field access:
instance.Field. When a parameter would shadow a type name, prefix within(e.g.inAccount: Account). - Operators (all binary operators take surrounding spaces):
- Comparison:
=,≠,≤,≥,<,>— standard numeric / value comparison. - Set notation:
x ∈ S("x is in S");A ⊆ B("A is a subset of B");A ⊇ B("A contains B" — used for open enums: "at least these members"). - Logic:
¬P("not P");∃ x ∈ S where P("some x in S satisfies P"). - Aggregation:
Σ S.Field(sum of.Fieldacross every element of S);max S.Field(largest such value);|x|(absolute value of x);x between A and B(shorthand forA ≤ x ≤ B). - Definition:
Foo := expressiondefinesFooas the named expression (used by theorems). - Constraint strength: MUST and SHOULD per RFC 2119. MUST = a hard invariant the system relies on; SHOULD = an expected condition whose violation surfaces as a dashboard exception.
- YAML key convention: SPEC type and field names are PascalCase; the YAML representation transliterates them to snake_case (
SourceRole→source_role). Role / Rail / Template names themselves stay PascalCase as identifier values.
Layer 1 — Universal Model¶
Primitives (Axioms)¶
Identity & labels:
- Entry: ordered sequence
- ID: opaque identifier
- Name: human-readable label
- Value: human-readable string
- Scope = {Internal, External}
Money:
- Currency: ISO 4217 code; the system is pinned to a single Currency
- Money: signed Decimal to 2dp in Currency
- Direction = {Debit, Credit}
- Amount: (Money, Direction)
- INVARIANT: Money ≥ 0 if Direction = Credit; Money ≤ 0 if Direction = Debit
Time:
- Timestamp: instant in UTC (integrators convert at the boundary)
- BusinessDay: (StartTime: Timestamp, EndTime: Timestamp)
- Duration: a span of time (used for aging windows)
Transfer machinery:
- Status ⊇ {Pending, Posted}
- TransferType ⊇ {Sale}
- Origin ⊇ {InternalInitiated, ExternalForcePosted}
- SupersedeReason ⊇ {Inflight, BundleAssignment, TechnicalCorrection}
- Metadata: Map[Name, Value]
Entities:
- Account: (ID, Name?, Parent?: Account, Scope, ExpectedEODBalance?: Money)
- Transfer: (ID, Completion: Timestamp, TransferType, Parent?: Transfer, ExpectedNet?: Money)
- Transaction: (Entry, ID, Account, Amount, Status, Posting: Timestamp, Transfer, Origin, BundleId?: ID, Supersedes?: SupersedeReason, Metadata)
- StoredBalance: (Entry, Account, BusinessDay, Money, Limits?: Map[TransferType, Money], Supersedes?: SupersedeReason)
Expected Implementation Entities:
- DailyBalance: StoredBalance + Account
- StoredTransaction: Transaction + Transfer
Status lifecycle¶
Transactions typically transition Pending → Posted via successive Entry rows of the same ID. A Pending Transaction is recorded but not yet considered settled fact — the integrator has captured the event but its required fields aren't all present yet. L1 invariants scope to Status = Posted because Pending values represent uncertainty about whether the event happened; counting them would produce false reconciliation results.
What makes a Transaction validly Posted is declared per-Rail in L2 (see PostedRequirements). Other state machines (e.g., Pending → Cancelled) are integrator-defined; the library does not interpret status values outside {Pending, Posted}.
Higher-Entry rows: inflight vs correction vs bundling¶
Every higher-Entry row that supersedes a previous Entry (for the same Transaction.ID or the same (StoredBalance.Account, StoredBalance.BusinessDay) pair) MUST set the typed Supersedes field naming the category. The categorization is determined by the prior row's state and the entity kind:
Categories applicable to Transaction:
Inflight— the prior row hadStatus = Pending. The new row completes the data (possibly transitioning Status to Posted, or just filling in more fields while still Pending). This is NOT a correction — nothing was wrong; the row was always going to fill in over time as the integrator's ETL caught up. Normal lifecycle progression.BundleAssignment— the prior row hadStatus = PostedandBundleIdNULL; the new row carriesBundleId = <bundle Transfer's id>, otherwise identical. The bundler consumed this Transaction and recorded which aggregating Transfer it folded into. Also not a correction — the prior row was correct, just unbundled.TechnicalCorrection— the prior row had wrong data and the new row changes one or more load-bearing values. This IS a correction — upstream wrote the wrong data, and the new row is what should have been written. The superseded row stays visible for audit.
Categories applicable to StoredBalance:
TechnicalCorrection— the prior snapshot value was wrong (either we recorded it wrong, or the source authority later restated). The new row carries the correctedMoneyvalue. The superseded row stays visible for audit.
Inflight and BundleAssignment do not apply to StoredBalance — snapshots don't have a Pending lifecycle and aren't bundled. Any higher-Entry StoredBalance is by construction a TechnicalCorrection.
The distinction matters because the dashboard / handbook surfaces these very differently:
- Inflight progressions are noise during normal operation; only an Inflight row that's been Pending for too long (per MaxPendingAge) is worth surfacing.
- Bundle assignments are operational — accountants want to see which bundle a row landed in, but it's not an exception.
- Technical corrections are exceptions worth investigation — somebody had bad data; the audit trail (prior + corrected entries) is what they need.
Recording the reason is load-bearing for the recon experience: an accountant looking at a row's history needs to immediately see whether the supersedence means "your ETL is fine, this row was just inflight" or "your upstream got something wrong here."
Derivatives (Theorems)¶
CurrentTransaction:={ tx ∈ Transaction : tx.Entry = max(Transaction(ID = tx.ID).Entry) }CurrentStoredBalance:={ sb ∈ StoredBalance : sb.Entry = max(StoredBalance(Account = sb.Account, BusinessDay = sb.BusinessDay).Entry) }ComputedBalance(inAccount: Account, inBusinessDay: BusinessDay):=Σ CurrentTransaction(Account = inAccount, Status = Posted, Posting ≤ inBusinessDay.EndTime).Amount.Money. Cumulative through end-of-day — every Posted transaction withPosting ≤ EndTimecontributes regardless of how far in the past it was, NOT just events oninBusinessDay. See the "Stored balance contract" note under System Constraints for the implementation contract this implies for integrators planting StoredBalance entries.Drift(inAccount: Account, inBusinessDay: BusinessDay):=CurrentStoredBalance(Account = inAccount, BusinessDay = inBusinessDay).Money − ComputedBalance(inAccount, inBusinessDay)LedgerDrift(inAccount: Account, inBusinessDay: BusinessDay):=CurrentStoredBalance(Account = inAccount, BusinessDay = inBusinessDay).Money − ( Σ CurrentTransaction(Account = inAccount, Status = Posted, Posting ≤ inBusinessDay.EndTime).Amount.Money + Σ CurrentStoredBalance(Account.Parent = inAccount, BusinessDay = inBusinessDay).Money )NetOfTransfer(inTransfer: Transfer):=Σ CurrentTransaction(Transfer = inTransfer, Status = Posted).Amount.MoneyIsParent(inAccount: Account):=∃ child ∈ Account where child.Parent = inAccountOutboundFlow(inAccount: Account, inTransferType: TransferType, inBusinessDay: BusinessDay):=Σ |CurrentTransaction(Account = inAccount, Transfer.TransferType = inTransferType, Amount.Direction = Debit, Status = Posted, Posting between inBusinessDay.StartTime and inBusinessDay.EndTime).Amount.Money|Age(inTransaction: Transaction):=now() − inTransaction.Posting
System Constraints¶
- Conservation: For every
t: Transferwheret.ExpectedNetis set,Σ CurrentTransaction(Transfer = t, Status = Posted).Amount.MoneySHOULD equalt.ExpectedNet. (Single-leg transfers leaveExpectedNetunset and are exempt; standard double-entry transfers setExpectedNet = 0.) - Timeliness: For every
tx: CurrentTransaction,tx.Posting ≤ tx.Transfer.CompletionSHOULD hold. Remediation is append-only — a violation (or any other Conservation-breaking condition) is corrected by posting a new Transaction against the same Transfer, not by amending the offending one. - BusinessDay enclosure: For every
tx: CurrentTransactionwheretx.Account.Scope = Internal, there MUST existsb: CurrentStoredBalance(Account = tx.Account)such thatsb.BusinessDay.StartTime ≤ tx.Posting ≤ sb.BusinessDay.EndTime. - Non-negative stored balance: For every
sb: CurrentStoredBalance,sb.MoneySHOULD be≥ 0. - Sub-ledger drift: For every
sb: CurrentStoredBalancewheresb.Account.Scope = Internaland¬IsParent(sb.Account),Drift(sb.Account, sb.BusinessDay)SHOULD equal0. - Ledger drift: For every
sb: CurrentStoredBalancewheresb.Account.Scope = InternalandIsParent(sb.Account),LedgerDrift(sb.Account, sb.BusinessDay)SHOULD equal0. - Parent balance existence: For every
sb: CurrentStoredBalancewheresb.Account.Parentis set, there MUST existCurrentStoredBalance(Account = sb.Account.Parent, BusinessDay = sb.BusinessDay). - Expected EOD balance: For every
sb: CurrentStoredBalancewheresb.Account.ExpectedEODBalanceis set,sb.MoneySHOULD equalsb.Account.ExpectedEODBalance. - Limit breach: For every
sb: CurrentStoredBalancewheresb.Limitsis set, for every(t, limit) ∈ sb.Limits, for every childc ∈ Account(Parent = sb.Account),OutboundFlow(c, t, sb.BusinessDay)SHOULD be≤ limit. (Limits live on the parent'sStoredBalanceand apply to each child individually — not aggregated across children.) - Immutability: Every
TransactionandStoredBalanceentity is immutable. Violations of constraints should be repaired by posting additional transactions. System errors may be corrected (but not hidden) by entering a higher entry; every higher Entry row MUST setSupersedesto record why.
Stored balance contract (implementation note for integrators planting StoredBalance rows): every StoredBalance row is an assertion that this account's cumulative net through that BusinessDay's end is exactly Money. The Sub-ledger drift / Ledger drift constraints check that assertion against ComputedBalance (see Theorems). Practically:
- A drift-free StoredBalance for (account, day) SHOULD have Money = ComputedBalance(account, day) — i.e., the cumulative net of every Posted transaction on this account with Posting ≤ day.EndTime.
- A StoredBalance posted at 0 for an account with prior posted activity will surface as drift equal to the negative of the cumulative — this is correct semantic, not a bug.
- To plant intentional drift for testing exception surfaces, set Money = ComputedBalance + delta and the Drift theorem returns delta.
- An account with no StoredBalance for a given BusinessDay is invisible to the Drift / Overdraft / Expected EOD constraints (they iterate over CurrentStoredBalance, not over Account). This is the right semantic — if the integrator never asserted a stored balance, there's nothing to compare against.
Design Principles¶
- Metadata promotion:
Metadatais opaque to System Constraints and Theorems — it carries values for display and integrator-defined filtering only. If a rule (a constraint, theorem, invariant, or scenario predicate) needs to read a value to evaluate, that value MUST be promoted out ofMetadatainto a typed field on the bearing entity. The set of typed fields is the set of load-bearing values; everything inMetadatais observational. - Three kinds of higher-Entry row (also see "Higher-Entry rows" above):
- Inflight progression (Transaction only; prior row was Pending) — the new row carries more complete data; possibly transitions Status to Posted. Not a correction; this is how integrator ETL completes a row over time.
Supersedes = Inflight. - Bundle assignment (Transaction only; prior row was Posted with BundleId NULL) — the bundler consumed this Transaction; the new row records its
BundleId. Not a correction.Supersedes = BundleAssignment. - Technical correction (Transaction or StoredBalance; prior row was wrong) — upstream wrote a wrong amount, wrong Account reference, wrong Parent, wrong daily balance, etc. The new row is what should have been written. The superseded row stays visible for audit.
Supersedes = TechnicalCorrection. - Business-process failures vs technical errors:
- Business-process failures (a real-world event went wrong — a wrong transfer was actually executed, a leg never posted, a balance ended overdrawn) are corrected by posting additional Transactions against the same Transfer, NOT by superseding existing rows. The original Transaction(s) stay as-is — they record what actually happened in the business.
- Technical errors (the system wrote the wrong row for a real-world event) are corrected by superseding (above).
- The distinction is "did the real-world event happen the way the row says?" If yes but our row is wrong → technical correction. If no, the row is correct as a record of what happened → fix by posting an additional Transaction.
- Account dimension is read-only: This system reads accounts from upstream and uses their typed structural attributes (
Scope,Parent,ExpectedEODBalance) to evaluate constraints. It does not provide tools to create, modify, or audit accounts.Account.Nameis a human-convenience display label and is not load-bearing for any constraint or theorem. - Implementation: Entities are stored in an append-only format with an automatically-incrementing
Entryid. Technical-error remediation MUST insert a new entity with a higherEntryid than the error's.
Layer 2 — Institutional Model¶
Purpose¶
LAYER 2 captures the integrator's institution: which accounts exist, what kinds of money movement the institution operates, how those movements relate, and what constraints apply. The library reads it to: - Scope LAYER 1 invariants to the institution's specifics. - Drive deterministic seed-data generation that exercises every declared rail. - Render handbook prose against the institution's vocabulary.
LAYER 2 is fully defined by the integrator. The library has no opinion on its content beyond providing the LAYER 1 building blocks the integrator's L2 expresses against.
How L2 plugs into L1¶
| L1 element | L2 contribution |
|---|---|
Account |
Declared per-instance and per-template by L2. |
TransferType (open enum) |
L2 contributes members. |
Transfer.ExpectedNet |
Set by L2 — per-Rail (standalone Transfers) or per-TransferTemplate (shared multi-leg Transfers). |
Transfer.Completion |
Set by L2 — per-Rail or per-TransferTemplate. |
Transaction.Account |
Resolved per leg from the firing Rail's SourceRole / DestinationRole / LegRole. When the role comes from an AccountTemplate, the concrete account instance is selected at posting time from the leg's Metadata. |
Transaction.Origin |
Declared per-leg per-Rail. The rail-level Origin field applies to all legs by default; SourceOrigin / DestinationOrigin override per-leg when the legs differ (e.g., the leg touching an external counterparty is ExternalForcePosted while the internal counterpart is InternalInitiated). |
Transaction.Posting |
Runtime / ETL-supplied; L2 does NOT contribute. |
Transaction.Amount |
Runtime / ETL-supplied; L2 does NOT contribute. |
Transaction.Status |
Lifecycle managed via L2's PostedRequirements. The library refuses to mark Status = Posted for a Transaction missing any of the firing Rail's PostedRequirements. |
Transaction.BundleId |
Populated by AggregatingRail bundlers; integrator's ETL leaves it NULL. |
Transaction.Metadata |
L2 declares the key set per Rail; values remain opaque runtime data. |
StoredBalance.Limits |
Populated from L2's Limit Schedules. |
L2 contributes no invariants of its own. All checks reduce to L1 invariants firing on data L2 has shaped.
Primitives¶
Description fields (optional, on every primitive)¶
Every L2 primitive (and the top-level L2Instance itself) carries an optional Description?: Value field. Free-form prose authored by the integrator — typically markdown — explaining what this entity is and why it exists. The library does no pre-processing; the value reaches handbook + training render templates as-is.
The field is optional at the type level (defaults to absent) for backward compatibility, but SHOULD be filled per RFC 2119 — handbook and training-scenario quality depends on it. An integrator skipping descriptions still gets functioning dashboards; what they lose is the auto-rendered prose explaining each entity's purpose.
Description: Value # markdown-friendly prose, single field, no schema beyond "string"
Why on every primitive (including ChainEntry and LimitSchedule which look like pure plumbing): training-scenario authoring needs the why context — "this XOR group exists because exactly one payout vehicle fires per cycle", "this cap exists because regulators require X" — not just the names. The handbook reads them to render entity-purpose paragraphs without authors having to reproduce the institutional knowledge inline in handbook source.
Per primitive's type signature below, Description? is shown as an optional last field; it is intentionally omitted from the worked-example YAML blocks to keep them shape-focused, but production L2 instances should fill it.
Instance Prefix (required)¶
A short SQL-identifier-safe string declared once at the top of the L2 instance. Applied to every generated database object and dashboard resource ID.
InstancePrefix: Identifier
Format: MUST match ^[a-z][a-z0-9_]*$ (lowercase start, alphanumeric or underscore thereafter), max 30 characters. The lowercase-only constraint avoids Postgres' quoted-vs-unquoted-identifier hazard; the 30-character cap leaves room for the longest table-name suffix within Postgres' 63-character identifier limit.
Two L2 instances coexist in one database by using distinct prefixes; cross-instance JOINs are not supported.
Prefix-based isolation (over Postgres schemas) is the default because not all deployment environments grant CREATE SCHEMA rights to the library's runtime; bare table/view name prefixing works everywhere.
Roles (open vocabulary)¶
Role: Identifier
An integrator-defined label for an Account or class of Accounts. Roles serve two purposes:
- Stable handle for Rails to reference accounts. A Rail that says
SourceRole: ConcentrationMasteris more portable thanSourceAccount: gl-1850, particularly when the referenced account comes from an AccountTemplate (many runtime instances of the same role). - Class label for templates.
Role: CustomerSubledgerlets thousands of customer-instance accounts share one declared shape.
Roles are open: the integrator declares whichever labels are useful. The library has no built-in roles.
Accounts (required: list of L1 Account)¶
1-of-1 accounts that exist exactly once in the institution. Each entry MUST populate the L1 required fields and SHOULD populate optional fields where they apply.
Account: (
ID,
Name?,
Role?: Role,
Scope,
ParentRole?: Role,
ExpectedEODBalance?: Money,
Description?: Value,
)
Notes:
- ParentRole references the parent by Role rather than by ID, so parent accounts that come from AccountTemplates are expressible. The library resolves ParentRole to a concrete L1 Account.Parent reference at materialization time.
- An Account whose Role is unique resolves any Rail reference to that role unambiguously.
Account Templates (optional: list)¶
A class of accounts that exists in many instances at runtime — one per customer, one per location, one per merchant. Declares the shape; concrete instances are materialized by the integrator's seed/ETL process.
AccountTemplate: (
Role,
Scope,
ParentRole?: Role,
ExpectedEODBalance?: Money,
Description?: Value,
)
When a Rail references a Role provided by an AccountTemplate, the Rail describes the SHAPE; the specific account instance for a given posting is selected at posting time, typically from the Transaction's Metadata (e.g., customer_id).
Constraints¶
- Singleton parent only.
ParentRoleMUST resolve to a singletonAccount, never to anotherAccountTemplate. Template-under-template nesting is forbidden because the per-instance parent assignment becomes ambiguous (which of N parent-template instances does a given child-template instance roll up to?). If per-customer subledger nesting is needed, model it by carryingcustomer_idas Metadata on a singleton-parented subledger rather than nesting accounts. - Name handling. Concrete-instance
Nameis integrator-supplied at materialization time (typically by the ETL/seed process). If not provided, the materializedIDis used as the display Name. AccountTemplate itself doesn't declare a name pattern — the library doesn't synthesize names from metadata.
Rails (required: list)¶
A canonical leg-pattern the institution operates. Each Rail produces one or two Transaction legs per firing.
Rail: (
Name,
TransferType, # extends L1 TransferType
MetadataKeys: [Identifier, …], # which Metadata keys legs may populate (informative)
# Origin — per-leg (extends L1 Origin). At least one resolution path MUST be available
# for every leg. See "Per-leg Origin" below.
Origin?: Origin, # default for all legs (shorthand when all legs share)
SourceOrigin?: Origin, # 2-leg only: override for source/debit leg
DestinationOrigin?: Origin, # 2-leg only: override for destination/credit leg
# Shape — exactly one of the two groups below:
# (a) Two-leg
SourceRole?: RoleExpression, # debit leg's account
DestinationRole?: RoleExpression, # credit leg's account
ExpectedNet?: Money, # required when this rail fires standalone Transfers
# (b) Single-leg
LegRole?: RoleExpression,
LegDirection?: {Debit, Credit, Variable},
# Optional flags
Aggregating?: Boolean, # see Aggregating Rails below
BundlesActivity?: [BundleSelector, …],
Cadence?: CadenceExpression,
# Inflight-handling (see "Inflight transaction handling" below)
PostedRequirements?: [Identifier, …], # additional integrator-declared posting requirements
MaxPendingAge?: Duration, # aging watch for Pending → Posted lag
MaxUnbundledAge?: Duration, # aging watch for Posted-but-not-bundled (only for bundled rails)
Description?: Value, # see "Description fields" above
)
RoleExpression: Role | (Role | Role | …) # union role; see below
BundleSelector: TransferType | RailName | TransferTemplateName | TransferTemplateName.LegRailName
Per-leg Origin¶
Transaction.Origin is a per-Transaction field at L1. Two legs of the same Rail commonly share an Origin (e.g., a fully-internal sweep where both legs are InternalInitiated), but real flows often need different Origins per leg — most commonly when one leg touches an external counterparty (ExternalForcePosted — the external party drove this) while the other touches an internal account (InternalInitiated — we recorded the response on our books).
Resolution rules:
- 1-leg rails: only Origin applies. SourceOrigin / DestinationOrigin are ignored if set (load-time warning).
- 2-leg rails:
- If Origin is set and neither override is set: both legs resolve to Origin.
- If SourceOrigin and DestinationOrigin are both set: each leg resolves to its respective override; rail-level Origin is ignored if also set (load-time warning).
- If only one of SourceOrigin / DestinationOrigin is set: the other leg resolves to rail-level Origin (which MUST then be set). If Origin is unset in this case, load-time error — the unspecified leg has no resolved Origin.
- At least one of Origin, SourceOrigin, DestinationOrigin MUST be sufficient to resolve both legs.
Two-leg rails¶
Declare both SourceRole (debit leg) and DestinationRole (credit leg). When fired as a standalone Transfer, ExpectedNet MUST be set (typically 0); L1 Conservation enforces Σ legs = ExpectedNet. When the rail is a leg-pattern of a TransferTemplate, ExpectedNet lives on the template, not the rail.
Single-leg rails¶
Declare LegRole and LegDirection. Per L1, the resulting Transfer leaves ExpectedNet unset and is exempt from Conservation in isolation. Single-leg rails (with Aggregating: false or unset) MUST be reconciled by EITHER:
- A TransferTemplate whose LegRails includes this rail (the shared Transfer's ExpectedNet provides closure via Conservation + Timeliness), OR
- An AggregatingRail whose BundlesActivity matches this rail (periodic reconciliation closes the drift).
A non-aggregating single-leg rail that meets neither condition is a configuration error — the drift it introduces would persist forever.
A rail MAY be reconciled by both (a leg of a TransferTemplate AND bundled by an AggregatingRail) — they reconcile different kinds of drift (transfer-net closure vs pool ledger drift). This combination is explicitly permitted.
Single-leg aggregating rails are exempt from the reconciliation rule above — they ARE the reconciliation mechanism (sweeping their drift into an External counterparty by design). They do not themselves appear in another rail's BundlesActivity.
LegDirection = Variable¶
Both the leg's amount AND direction are determined at posting time by surrounding context — specifically, by the requirement that a containing TransferTemplate's ExpectedNet hold given the other legs already posted. A "settlement" leg that posts whatever amount/direction closes the bundle is the canonical case.
A TransferTemplate MUST contain at most one Variable-direction leg per shared Transfer. Two or more Variable legs leave the closure under-determined; the library detects this at load-time validation, not at posting.
A Variable-direction leg MUST be the LAST leg posted on its Transfer — all sibling legs MUST be Status = Posted (not Pending) before the Variable leg posts. Posting a Variable leg while sibling legs are still Pending is a posting-time error (the closure amount can't be computed against incomplete data).
Union roles¶
(RoleA | RoleB) — a Role field MAY express that the rail can target accounts of more than one role. Each firing still resolves to one concrete role per leg; the union is about which roles are admissible, not about firing multiple legs at once.
Rail uniqueness (per-leg (TransferType, Role) discriminator)¶
Every Rail contributes one or more (TransferType, Role) discriminators to the L2 instance, one per leg:
- A two-leg Rail contributes two —
(TransferType, SourceRole)and(TransferType, DestinationRole). - A single-leg Rail contributes one —
(TransferType, LegRole). - Union role expressions contribute one discriminator per role in the union.
These discriminators MUST be unique across rails. The Rail-to-Transaction binding is implicit: the (transfer_type, account_role) tuple of a posted Transaction identifies which Rail produced it. Two rails sharing a discriminator make a candidate Transaction match both with no defined tiebreak — the runtime can't tell which rail's invariants (ExpectedNet, PostedRequirements, MaxPendingAge, etc.) to apply.
Direction is intentionally NOT in the discriminator. A Rail named CustomerInboundACH (source: ExternalCounterparty, destination: CustomerDDA, transfer_type: ach) and a Rail named CustomerOutboundACH (source: CustomerDDA, destination: ExternalCounterparty, transfer_type: ach) both contribute (ach, ExternalCounterparty) and (ach, CustomerDDA) — they collide.
When the integrator's chart of accounts genuinely has direction-specific rails, resolve the collision by:
- (a) Distinct directional
TransferTypes — e.g.ach_inbound+ach_outbound. Each rail's discriminators stay unique. Recommended when the two directions have different LimitSchedule caps, PostedRequirements, or aging tolerances. - (b) Merge into one bidirectional rail — collapse Inbound + Outbound into a single Rail; treat direction as a Metadata field. Recommended when the two directions are mirror images of each other.
- (c) Chain via TransferTemplate — model the back-and-forth as a multi-leg shared Transfer. Appropriate when the two directions belong to one logical financial event.
Rationale: forcing this resolution at load time prevents the silent ambiguity of two rails matching the same Transaction. Within a single rail, both legs sharing a Role (e.g., a pool-balancing rail with source = destination = same control account) is fine — the legs share a transfer_id so the binding remains unambiguous.
Aggregating Rails (Rail variant)¶
A Rail with Aggregating: true sweeps activity from many other Transfers over a period without being chain-related to any one of them. Pool-to-pool balancing, periodic clearing settlements, EOM interest sweeps.
# Same Rail shape as above, plus:
Aggregating: true
BundlesActivity: [BundleSelector, …]
Cadence: CadenceExpression
BundlesActivity is the aggregating-rail equivalent of Chain — it expresses which activity the rail rolls up over, in lieu of explicit parent-child chain entries.
BundleSelector semantics¶
A BundleSelector matches eligible activity by union (OR):
- TransferType — every Transaction whose Transfer has this type.
- RailName — every Transaction produced by that specific rail.
- TransferTemplateName — every Transaction belonging to a Transfer of that template (i.e., every leg of that template's Transfers).
- TransferTemplateName.LegRailName — every Transaction belonging to a Transfer of that template AND produced by that specific leg-pattern rail. Use this to scope to one leg of a multi-leg template.
A single Transaction matched by multiple selectors counts once toward the bundle.
A Transaction is eligible when:
- Status = Posted
- BundleId IS NULL
- It matches the AggregatingRail's BundlesActivity
Bundling semantics (append-only)¶
When an AggregatingRail fires:
1. Bundler queries eligible Transactions matching BundlesActivity.
2. Bundler computes the net Amount across them.
3. Bundler creates a new Transfer (the aggregating Transfer) with a fresh ID — call it bundle_id — and posts the rail's leg(s) against it.
4. For each consumed source Transaction, bundler appends a higher-Entry Transaction row with BundleId = bundle_id, Supersedes = BundleAssignment. Per L1 append-only, the original row is preserved; CurrentTransaction(ID = tx.ID) is now the higher-Entry one.
This pattern keeps consumed-tracking append-only — no row mutation — and preserves a full audit trail of when each Transaction was bundled and into which aggregating Transfer.
Late-arriving Pending rows (M.3.13)¶
A Transaction whose Pending row arrives after a previous bundler firing has already closed for the same Rail's eligibility window is bundled by the next bundler firing — not retroactively into the closed bundle. The bundler treats eligibility purely on current state at the moment it runs (Status = Posted AND BundleId IS NULL), so a late-arriving leg whose Pending → Posted transition completes after the previous cadence boundary lands in whatever bundle is open the next time the bundler fires.
Concretely:
- The previous bundle's BundleId reflects the bundler's firing day, not the consumed source rows' posted_at days.
- A row that was already Posted at the previous firing but somehow missed the eligibility query (network blip, RDBMS replication lag) gets picked up on the next firing — same mechanism, just shifted one cadence cycle.
- MaxUnbundledAge measures wall-clock age of the Posted-and-eligible state, not bundler latency. An aggressive MaxUnbundledAge shorter than the bundler's cadence period intentionally surfaces every late-arriving row as a SHOULD-violation; the integrator chooses how aggressive to set it relative to the cadence.
This is intentional: the alternative (re-opening a closed bundle to add late rows) would mutate BundleAssignment rows after the fact and break L1's append-only invariant. Late rows always wait for the next firing.
CadenceExpression vocabulary (v1)¶
| Literal | Meaning |
|---|---|
intraday-Nh |
Every N hours during the business day (e.g., intraday-2h). |
daily-eod |
Once at end of business day. |
daily-bod |
Once at start of business day. |
weekly-<weekday> |
Once per week on the named weekday (e.g., weekly-fri). |
monthly-eom |
Once at end of calendar month. |
monthly-bom |
Once at start of calendar month. |
monthly-<day> |
Once per month on the named day (e.g., monthly-15). |
Cadences outside this vocabulary are not recognized in v1; the library rejects unknown literals at load time. Extending the vocabulary is a SPEC change, not an integrator-supplied resolver.
Constraints¶
- An Aggregating rail MUST NOT appear as
Childin any Chain entry. It runs on the declared cadence, sweeping up activity matchingBundlesActivitythat is eligible but not yet bundled. - Aggregating rails are typically two-leg, but single-leg aggregating rails are permitted (e.g., a single-leg sweep that lands in an external counterparty).
The library uses Aggregating: true to render these rails distinctly from the per-transfer chain DAG and to skip them in chain-validity checks.
Transfer Templates (optional: list)¶
Most Rails fire 1:1 with Transfers (one Rail firing produces one Transfer). Some flows are inherently multi-leg: many Rails firing accumulate as legs into ONE shared Transfer, whose ExpectedNet and Completion close the bundle.
TransferTemplate: (
Name,
TransferType, # the shared Transfer's TransferType
ExpectedNet: Money, # MUST be set
TransferKey: [MetadataKey, …], # values whose equality groups legs onto one Transfer
Completion: CompletionExpression, # how Transfer.Completion is derived
LegRails: [RailName, …], # which Rails fire as legs into this Transfer
Description?: Value, # see "Description fields" above
)
Semantics: every firing of a LegRails rail with the same TransferKey values posts to the same shared Transfer.
- L1 Conservation flags the Transfer if its legs don't sum to ExpectedNet (catches missing legs, including a missing closing leg).
- L1 Timeliness flags the Transfer if any leg posts after Completion (catches late closure).
This is the L2 mechanism that bridges single-leg Rails to L1 enforcement: a single-leg posting that's individually exempt from Conservation IS subject to it as a leg of a TransferTemplate that requires net-zero closure by deadline.
A Rail listed in LegRails of a TransferTemplate MUST NOT also fire standalone Transfers — its firings always join the shared Transfer for the matching TransferKey.
TransferKey semantics¶
TransferKey declares which Metadata KEYS participate in the grouping rule (schema-level). The runtime VALUES under those keys remain opaque integrator-supplied data — consistent with L1's Metadata Promotion principle, which governs values, not key declarations.
TransferKey values are auto-derived as PostedRequirements for every Rail in LegRails: a leg whose Metadata is missing one or more declared TransferKey keys (or whose value is NULL) cannot be Posted, because it can't be assigned to a shared Transfer. Integrators don't need to repeat TransferKey fields in each Rail's PostedRequirements; the library projects them automatically.
Transfer ID derivation (lookup-or-create)¶
For TransferTemplate-based Transfers, the Transfer's L1 ID is allocated lookup-or-create: the first leg posting for a given (template, TransferKey-values) tuple creates a Transfer with a fresh ID; subsequent legs query by (template, TransferKey-values) and post against that ID.
Implementers MUST treat this as a known failure point. Concurrent posters racing on the first leg for a key can produce duplicate Transfers — fix path is L1's append-only entry correction (post a higher-Entry version of the duplicate's legs pointing them at the surviving Transfer ID; supersede the duplicate Transfer record). The library SHOULD provide a uniqueness constraint on (template_name, transfer_key_values) at the storage layer to catch the race at write time rather than at reconciliation time.
For ordinary business processing — where an integrator's ETL is well-behaved — lookup-or-create works without intervention. It's the high-throughput / concurrent-poster scenarios that need the entry-correction fallback.
TransferKey scope and field-name validity (M.3.13)¶
TransferKey values are scoped by (template_name, transfer_key_values), not by transfer_key_values alone. Two different templates that happen to declare the same TransferKey field names with the same values do NOT collide on a shared Transfer — the template name disambiguates them. So TemplateA(transfer_key=[merchant_id, period]) and TemplateB(transfer_key=[merchant_id, period]) firing simultaneously with merchant_id="m1", period="2026-04" produce two distinct Transfers (one per template).
Every TransferKey field name MUST also appear in every leg_rail's MetadataKeys (validator R12 — see Validation rules above). The auto-derivation chain is: TransferKey → PostedRequirements → ETL must populate. If the field isn't in the rail's MetadataKeys, ETL has no schema slot to populate it — the leg can't reach Posted, and the rail is dead.
A TransferKey value of NULL (or an empty string after trimming whitespace, where the integrator's storage layer surfaces "empty" as distinct from NULL) is treated as "missing" for grouping purposes — the leg can't be Posted because its grouping membership is undefined. This mirrors the SPEC's general "TransferKey value is NULL → leg can't be Posted" rule above.
CompletionExpression vocabulary (v1)¶
| Literal | Meaning |
|---|---|
business_day_end |
End of the BusinessDay the Transfer was opened. |
business_day_end+Nd |
End of the BusinessDay N business days after open (e.g., business_day_end+3d). N counts business days, skipping weekends and holidays per the integrator-supplied business calendar. |
month_end |
End of the calendar month the Transfer was opened. |
metadata.<key> |
Resolves to the Timestamp value at Metadata key <key> on any leg of the Transfer. ETL is responsible for pre-computing this value and posting it on at least one leg. |
Expressions outside this vocabulary are not recognized in v1; the library rejects unknown literals at load time.
Chains (optional: list)¶
Parent → child relationships between Rails or Transfer Templates. Used to:
- Validate that a Transfer's L1 Parent reference matches an allowed pattern.
- Render multi-stage pipelines.
- Generate orphan checks (every required parent SHOULD have a corresponding child).
ChainEntry: (
Parent: RailName | TransferTemplateName,
Child: RailName | TransferTemplateName,
Required: Boolean,
XorGroup?: Identifier,
Description?: Value, # see "Description fields" above
)
Resolution:
- When Parent is a Rail, child Transfers' L1 Parent reference points to the parent Rail's Transfer.
- When Parent is a TransferTemplate, child Transfers' L1 Parent reference points to the shared Transfer (not to any one of its component leg postings).
Required: true — every parent Transfer firing SHOULD eventually have at least one matching child Transfer firing. A missing child surfaces as an orphan exception (RFC 2119 SHOULD: violation surfaces as a dashboard exception, not a hard failure).
When a chain entry has Required: true, the child Rail's parent_transfer_id field is auto-derived as a PostedRequirement — the child can't be Posted without naming its parent. (When Required: false, the parent is genuinely optional; parent_transfer_id may be NULL on the child's Posted legs.)
XOR groups¶
When several chain entries share the same Parent AND the same XorGroup, exactly one of them SHOULD fire per parent Transfer instance. Without XorGroup, multiple Required: false children allow any combination including none.
XOR groups capture flows like: - "Exactly one of {success path, reversal path} happens for an escrow transfer." - "Exactly one of {ACH payout, wire payout, internal payout} fires per settlement cycle."
The library evaluates XOR-group membership: missing-firings AND multiple-firings both surface as exceptions when at least one chain entry in the group has Required: true. (If all Required: false and XorGroup is set, it means "at most one" rather than "exactly one.")
Reversals¶
Reversals are not a separate L2 primitive. A reversal is a Rail (typically with the same shape as the original but opposite-direction leg) participating in an XOR group with the success Rail — the success-vs-reversal example above is the canonical pattern.
Limit Schedules (optional: list)¶
Daily caps on outbound flow per (parent role, transfer type). Time-invariant in v1.
LimitSchedule: (
ParentRole: Role,
TransferType,
Cap: Money,
Description?: Value, # see "Description fields" above
)
The library projects each LimitSchedule entry into the relevant StoredBalance.Limits map for every StoredBalance of every account whose Role matches ParentRole, for every BusinessDay. L1's Limit Breach invariant then evaluates per child individually (the cap is per-child, not aggregated across siblings of the parent).
The combination (ParentRole, TransferType) MUST be unique across LimitSchedule entries — duplicate combinations are a load-time configuration error.
Inflight transaction handling¶
L2 needs to reason about Transactions in flight: those that are recorded but not yet eligible to count as settled fact, and those that are settled but not yet bundled. This section covers the declarative knobs and the lifecycle.
Lifecycle (per Transaction)¶
[ETL writes row] → Pending → Posted, BundleId NULL → Posted, BundleId set
↑ ↑ ↑
Status = Pending PostedRequirements AggregatingRail bundler
(some required all populated consumes and assigns
fields may (higher-Entry row, (higher-Entry row,
still be NULL) Supersedes = Inflight) Supersedes = BundleAssignment)
────────────── ──────────────────
MaxPendingAge MaxUnbundledAge
watches this watches this
Each transition is a higher-Entry row of the same Transaction ID, with Supersedes recording the category. Per L1's "Three kinds of higher-Entry row":
- Pending → (more complete Pending OR Posted) is Inflight — normal lifecycle progression, NOT a correction.
- Posted → Posted-with-BundleId is BundleAssignment.
- Posted → Posted-with-different-data is TechnicalCorrection (upstream got it wrong).
The first two are normal operation. Only the third is an exception worth surfacing.
Not every Transaction goes through every state. Specifically:
- A Transaction whose Rail has no PostedRequirements (or whose ETL writes the row already complete) may be Posted from creation — no Pending state, no Inflight supersedence.
- A Transaction whose Rail isn't matched by any AggregatingRail's BundlesActivity stays at "Posted, BundleId NULL" forever — that's correct, no BundleAssignment will ever happen. The MaxUnbundledAge watch only applies if the Rail IS bundled.
PostedRequirements¶
Declares the field set that MUST be populated for a Transaction to legitimately have Status = Posted. The library refuses to mark Status = Posted for a Transaction missing any of these fields; the Transaction stays Pending until a higher-Entry row supplies the missing data.
The library auto-derives PostedRequirements entries from structural declarations:
- Every field in a containing TransferTemplate's TransferKey.
- parent_transfer_id if the Rail appears as Child in a chain entry with Required: true.
The integrator's PostedRequirements declaration adds Rail-specific requirements on top of these auto-derived entries.
Examples of integrator-added requirements:
- A card-spend Rail might require [card_brand, mcc, merchant_descriptor] because absent any of those, the row isn't reconcilable.
- An ACH Rail might require [external_reference] because the trace number is needed to match against the bank statement.
MaxPendingAge¶
The longest acceptable interval between a Transaction's Pending posting and its transition to Posted. Pending Transactions older than this surface as exceptions ("stale Pending").
- SHOULD constraint per RFC 2119 — surfaces as dashboard exception, not a hard failure.
- Catches systemic ETL failures (a feed stopped delivering settlement files; a queue is backed up; a key field is being dropped at the source) that would otherwise hide behind aggregation.
- Distinct from chain orphan checks (which fire on missing child Transfers) and Conservation (which fires on Posted-leg sums) — those check structure; this one checks ETL liveness.
MaxUnbundledAge¶
The longest acceptable interval between a Transaction becoming Posted-and-eligible-for-bundling and being assigned a BundleId. Posted-and-unbundled Transactions older than this surface as exceptions ("stale Unbundled").
- Only meaningful when the Rail's transactions are bundled (i.e., something else has them in
BundlesActivity). - Catches bundler liveness — distinct from MaxPendingAge (which catches incomplete data).
Implementation notes¶
- Each L2 instance is fully isolated by its
InstancePrefix. Every generated database object and every dashboard resource ID is prefixed. - Production integrators typically run one L2 instance under a stable production prefix. Demo and test runs use ephemeral or fixture-specific prefixes so they never collide.
- The library validates the L2 instance at load time. Configuration errors are reported at load, not at posting time.
Validation rules¶
Every rule below is enforced at YAML load time — load_instance(path) runs the full cross-entity validation pass before returning, so an integrator authoring a malformed L2 instance fails at parse time rather than at first render. Violations raise L2ValidationError with a logical-path message identifying the offending field. (Tests that need to construct intentionally-incomplete instances may opt out via load_instance(path, validate=False).)
- Every
Rolereferenced by a Rail or AccountTemplate resolves to either a declaredAccountor anAccountTemplate. - Every
RailNamein aTransferTemplate.LegRailsorChainEntryexists. - Every
TransferTemplateNamein aChainEntryorBundleSelectorexists. - Every
AccountTemplate.ParentRoleresolves to a singletonAccount(NOT anotherAccountTemplate). - Every single-leg Rail (with
Aggregating: falseor unset) is reconciled — appears as a leg of a TransferTemplate AND/OR is matched by an AggregatingRail'sBundlesActivity. - Every TransferTemplate contains at most one
LegDirection: Variableleg. - Every
TransferTemplate.LegRailsentry references a non-Aggregating Rail. (Aggregating rails sweep on a cadence and don't carry the per-instance identity a TransferKey-grouped template needs.) - Every
Aggregating: trueRail is absent fromChildpositions in chains. - Every
XorGroupmembership is consistent (all members shareParent). - Every
CompletionandCadenceliteral is in the v1 vocabulary. - Every
LimitSchedule(ParentRole, TransferType)combination is unique. (M.2d.2) Duplicate combinations are ambiguous — the projection intoStoredBalance.Limitswould have two competing caps, and the CASE-branch render order in the limit-breach matview silently picks the first match. Caught at YAML load. - Every
MaxUnbundledAgeis set only on Rails that appear in some AggregatingRail'sBundlesActivity(otherwise the watch can never fire). - Every
BundleSelectorof the formTransferTemplateName.LegRailNamereferences a rail that's actually in that template'sLegRails. - Every leg of every Rail resolves to an Origin (per the resolution rules in "Per-leg Origin"). Unresolved legs are a load-time configuration error.
- Per-leg overrides (
SourceOrigin,DestinationOrigin) appear only on 2-leg rails. Their presence on a 1-leg rail is a load-time warning (the field is ignored). - Every L2-instance reference to a
TransferTypestring MUST resolve to some Rail's declaredTransferType. (M.2d.1) Concretely: everyLimitSchedule.TransferTypematches someRail.TransferType, and every bare-form (<name>, notTemplate.LegRail) entry in an AggregatingRail'sBundlesActivityresolves to either a declaredRail.NameOR some declaredRail.TransferType. Catches typos in cap declarations and bundle selectors that would otherwise silently no-op. (The runtime invariant — every posted Transaction'sTransferTypematches some Rail — is the L3 surface, slated for M.2d.4 as a SHOULD-constraint matview rather than a load-time validator.) - Every
TransferKeyfield name MUST appear inMetadataKeysof every Rail in the template'sLegRails. (M.3.13) TransferKey fields are auto-derived asPostedRequirementsfor every leg_rail; if the field isn't declared in the rail'sMetadataKeys, the integrator's ETL has no legitimate place to populate it — the column simply doesn't exist on the rail's posting shape — and the leg can never reachStatus = Posted. Caught at YAML load instead of at first posting attempt. - Every Variable-direction
SingleLegRailMUST appear in someTransferTemplate.LegRails. (M.3.13) Variable closure semantics require a containing template'sExpectedNetto compute the leg's amount + direction at posting time. A Variable rail reconciled only by an AggregatingRail (the alternate S3 reconciliation path) has no closure target — the bundler computes its own amount, not a closure. Caught at YAML load. - Every
XorGroupMUST have at least 2 members. (M.3.13) A single-member XOR group is degenerate: "exactly one of one option happens" trivially holds whenever the parent fires, so the declaration adds no constraint. In practice this is a typo (the second member'sXorGroupstring disagrees) or a leftover from a deletion. Caught at YAML load so the misconfig can't silently weaken the dashboard's XOR-violation detection. - Every key in a Rail's
MetadataValueExamplesMUST appear in the same Rail'sMetadataKeys. (M.4.2b)MetadataValueExamplesis the optional per-key example value map the demo seed's broad-mode plant generator uses to render persona-aware metadata cascade values (cycling through declared examples by firing seq; falling back to a synthetic per-firing string when a key has no examples). A typo'd example-list key would silently never be used by the seed picker — the integrator would never see a feedback signal that their example data is wrong. Caught at YAML load.
Worked example shapes¶
Singleton account¶
- id: clearing-suspense
name: Clearing Suspense
role: ClearingSuspense
scope: internal
expected_eod_balance: 0
Account template¶
- role: CustomerSubledger
scope: internal
parent_role: CustomerLedger
# Assumes a singleton Account with role: CustomerLedger declared
# elsewhere in the same instance.
Two-leg standalone rail (shared Origin)¶
- name: InternalSweep
transfer_type: sweep
source_role: ClearingSuspense
destination_role: NorthPool
expected_net: 0
origin: InternalInitiated # both legs are internal-initiated
metadata_keys: [business_day]
Two-leg rail with per-leg Origin¶
- name: ExternalRailInbound
transfer_type: ach
source_role: ExternalCounterparty
destination_role: ClearingSuspense
expected_net: 0
source_origin: ExternalForcePosted # external party drove the inbound
destination_origin: InternalInitiated # we recorded the credit on our books
metadata_keys: [external_reference, originator_id]
posted_requirements: [external_reference] # bank reference number is required (integrator-declared)
max_pending_age: PT24H # ETL should complete within a day
Two-leg rail with union destination role¶
- name: InternalPayout
transfer_type: internal_transfer
source_role: MerchantLedger
destination_role: (MerchantLedger | CustomerSubledger) # union — either is admissible
expected_net: 0
origin: InternalInitiated
metadata_keys: [paying_merchant_id, receiving_party_id, party_kind]
posted_requirements: [party_kind] # ETL MUST tag which kind of destination this is
Single-leg debit rail¶
- name: SubledgerCharge
transfer_type: charge
leg_role: CustomerSubledger
leg_direction: Debit
origin: InternalInitiated
metadata_keys: [merchant_id, customer_id, settlement_period]
max_unbundled_age: PT4H # PoolBalancing should sweep within 4 hours
# TransferKey fields (merchant_id, settlement_period) auto-derived to
# PostedRequirements via MerchantSettlementCycle below.
Single-leg credit rail (mirror)¶
- name: SubledgerRefund
transfer_type: refund
leg_role: CustomerSubledger
leg_direction: Credit
origin: InternalInitiated
metadata_keys: [merchant_id, customer_id, settlement_period, original_charge_id]
max_unbundled_age: PT4H
# Posted as a leg of MerchantSettlementCycle alongside SubledgerCharge.
Single-leg variable-direction rail¶
- name: SettlementClose
transfer_type: settlement
leg_role: MerchantLedger
leg_direction: Variable
origin: InternalInitiated
metadata_keys: [merchant_id, settlement_period]
# Direction + amount determined by the TransferTemplate's net-zero
# requirement; MUST be the last leg posted on its Transfer.
Transfer template¶
- name: MerchantSettlementCycle
transfer_type: settlement_cycle
expected_net: 0
transfer_key: [merchant_id, settlement_period]
completion: metadata.settlement_period_end
leg_rails:
- SubledgerCharge
- SubledgerRefund
- SettlementClose
Aggregating rail (two-leg, intraday) — demonstrating BundleSelector forms¶
- name: PoolBalancingNorthToSouth
transfer_type: pool_balancing
source_role: NorthPool
destination_role: SouthPool
expected_net: 0
origin: InternalInitiated
metadata_keys: [bundled_transfer_type, business_day]
aggregating: true
cadence: intraday-2h
bundles_activity:
# Leg-scoped — only these specific leg-patterns of MerchantSettlementCycle
- MerchantSettlementCycle.SubledgerCharge
- MerchantSettlementCycle.SubledgerRefund
- MerchantSettlementCycle.SettlementClose
# RailName form — every Transfer produced by this standalone rail
- InternalPayout
# TransferType form — every Transfer of this type, regardless of producing rail
- cross_world_transfer
Aggregating rail (single-leg, monthly)¶
- name: ExternalFeeAssessment
transfer_type: fee
leg_role: ExternalCounterparty
leg_direction: Debit
origin: ExternalForcePosted
metadata_keys: [accrual_period]
aggregating: true
cadence: monthly-eom
bundles_activity: [SubledgerCharge]
# Single-leg aggregating rail — exempt from "must be reconciled by another rail."
# By design it sweeps drift into an external counterparty.
Chain — XOR group with TransferTemplate parent¶
- parent: MerchantSettlementCycle
child: MerchantPayoutACH
required: false
xor_group: PayoutVehicle
- parent: MerchantSettlementCycle
child: MerchantPayoutWire
required: false
xor_group: PayoutVehicle
- parent: MerchantSettlementCycle
child: MerchantPayoutInternal
required: false
xor_group: PayoutVehicle
# Exactly one of the three vehicles fires per settlement cycle.
Chain — fan-out (one parent, many children)¶
- parent: BatchInbound
child: PerRecipientCredit
required: true
# Required: true on a one-to-many fan-out means at least one child
# must fire (typical: many fire, one per item in the batch). The
# child's parent_transfer_id is auto-added to its PostedRequirements.
Limit schedule¶
- parent_role: NorthPool
transfer_type: ach
cap: 5000.00
End-to-end: a complete merchant-acquiring instance¶
This example exercises every L2 primitive — singleton accounts, account templates, two-leg + single-leg + variable-direction + aggregating rails, per-leg Origin, union roles, transfer templates, chains with XOR groups, limit schedules, PostedRequirements, MaxPendingAge, MaxUnbundledAge, and BundleSelector in three forms.
instance: example_acquirer
# ---- Singleton accounts -----------------------------------------------------
accounts:
- id: north-pool
role: NorthPool
scope: internal
- id: south-pool
role: SouthPool
scope: internal
- id: clearing-suspense
role: ClearingSuspense
scope: internal
expected_eod_balance: 0
- id: ext-counter
role: ExternalCounterparty
scope: external
# ---- Account templates (multi-instance) -------------------------------------
account_templates:
- role: CustomerSubledger
scope: internal
parent_role: SouthPool
- role: MerchantLedger
scope: internal
parent_role: NorthPool
# ---- Rails ------------------------------------------------------------------
rails:
# ===== Leg patterns of MerchantSettlementCycle (single-leg) =================
- name: SubledgerCharge
transfer_type: charge
leg_role: CustomerSubledger
leg_direction: Debit
origin: InternalInitiated
metadata_keys: [merchant_id, customer_id, settlement_period, settlement_period_end]
max_pending_age: PT4H # ETL should complete within 4h
max_unbundled_age: PT4H # PoolBalancing should sweep within 4h
- name: SubledgerRefund
transfer_type: refund
leg_role: CustomerSubledger
leg_direction: Credit
origin: InternalInitiated
metadata_keys: [merchant_id, customer_id, settlement_period, settlement_period_end, original_charge_id]
max_pending_age: PT4H
max_unbundled_age: PT4H
- name: SettlementClose
transfer_type: settlement
leg_role: MerchantLedger
leg_direction: Variable # amount + direction set by Transfer's net-zero
origin: InternalInitiated
metadata_keys: [merchant_id, settlement_period, settlement_period_end]
max_unbundled_age: PT4H
# ===== Vehicle Transfers (chained children of the settlement cycle) ========
# Vehicle 1: outbound ACH — per-leg Origin (internal sweep + external landing)
- name: MerchantPayoutACH
transfer_type: ach
source_role: MerchantLedger
destination_role: ExternalCounterparty
expected_net: 0
source_origin: InternalInitiated # we initiated the debit on the merchant
destination_origin: ExternalForcePosted # external bank's books are where it lands
metadata_keys: [merchant_id, settlement_period, external_reference]
posted_requirements: [external_reference] # bank trace number required
max_pending_age: PT24H
# Vehicle 2: internal payout — union destination role
- name: MerchantPayoutInternal
transfer_type: internal_transfer
source_role: MerchantLedger
destination_role: (MerchantLedger | CustomerSubledger) # could be either
expected_net: 0
origin: InternalInitiated
metadata_keys: [merchant_id, settlement_period, receiving_party_id, receiving_party_kind]
posted_requirements: [receiving_party_kind] # disambiguator for the union
# ===== Aggregating rail (closes pool drift) ================================
- name: PoolBalancingSouthToNorth
transfer_type: pool_balancing
source_role: SouthPool
destination_role: NorthPool
expected_net: 0
origin: InternalInitiated
metadata_keys: [bundled_transfer_type, business_day]
aggregating: true
cadence: intraday-2h
bundles_activity:
# Leg-scoped: just these legs of MerchantSettlementCycle
- MerchantSettlementCycle.SubledgerCharge
- MerchantSettlementCycle.SubledgerRefund
- MerchantSettlementCycle.SettlementClose
# ---- Transfer template ------------------------------------------------------
transfer_templates:
- name: MerchantSettlementCycle
transfer_type: settlement_cycle
expected_net: 0
transfer_key: [merchant_id, settlement_period]
completion: metadata.settlement_period_end
leg_rails:
- SubledgerCharge
- SubledgerRefund
- SettlementClose
# ---- Chains -----------------------------------------------------------------
chains:
# Exactly one payout vehicle per settled merchant — XOR group:
- parent: MerchantSettlementCycle
child: MerchantPayoutACH
required: false
xor_group: PayoutVehicle
- parent: MerchantSettlementCycle
child: MerchantPayoutInternal
required: false
xor_group: PayoutVehicle
# ---- Limit schedules --------------------------------------------------------
limit_schedules:
- parent_role: SouthPool
transfer_type: charge
cap: 5000.00 # per-customer daily charge cap
What this composes:
- Charges and refunds post as single-leg debits/credits to individual customer subledgers as they happen. Both fire as legs of the per-(merchant, settlement_period) shared Transfer. merchant_id and settlement_period are auto-derived as PostedRequirements via TransferKey; the integrator declares no extra requirements on them.
- At period end, SettlementClose fires once per merchant with the net amount and direction needed to bring the shared Transfer to ExpectedNet=0. L1 Conservation flags the Transfer if SettlementClose never fires; Timeliness flags it if any leg posts after the period's settlement_period_end.
- PoolBalancingSouthToNorth runs every 2 hours, sweeping the pool drift the single-leg activity creates. Leg-scoped BundleSelectors confine its sweep to MerchantSettlementCycle's leg postings only.
- After SettlementClose, exactly one of the two payout vehicles fires per settled merchant (XOR group PayoutVehicle):
- MerchantPayoutACH — outbound ACH; per-leg Origin distinguishes the internal merchant-debit (InternalInitiated) from the external bank landing (ExternalForcePosted). Requires a bank trace number (external_reference) before it can be Posted.
- MerchantPayoutInternal — same-system payout to either another merchant OR a customer subledger (union destination role). The integrator's ETL must tag receiving_party_kind so the destination role resolves unambiguously.
- Aging watches catch operational failures distinctly from structural ones: MaxPendingAge flags ETL stuck-Pending; MaxUnbundledAge flags bundler-stuck-Posted. Both are operational health checks, not structural exceptions.
- Auto-derived PostedRequirements ensure structural integrity: TransferKey fields can't be NULL on leg postings; parent_transfer_id can't be NULL on a required: true chained child. Integrators add their own (e.g., external_reference on the ACH payout, receiving_party_kind on the internal payout) for domain-specific completeness.
Layer 3 — Applications¶
Purpose¶
LAYER 3 is a set of dashboard applications, each answering one question shape the L1 primitives and L2 instance can produce. The shipped apps are deliberately small and orthogonal: each answers a question the others cannot, so the user reaches for a specific app based on the shape of the question. Adding a fifth shipped app should require justifying that no existing app's stepback already covers it.
Question shapes¶
| App | Question shape | Primary audience | L1 primitives leaned on |
|---|---|---|---|
| L1 Reconciliation Dashboard | Are the institution's L1 invariants holding right now? Where are they breaking? | Accountant | Drift, LedgerDrift, OutboundFlow (limit breach), Age (stuck pending / unbundled), Status, Supersedes |
| L2 Flow Tracing | Did this transfer (or transfer type) post the way L2 says it should? | Accountant + Integration Engineer | CurrentTransaction, NetOfTransfer, PostedRequirements, Origin |
| Investigation | What's flowing between accounts, and which flows are anomalous? | Accountant + Third-party (compliance) | CurrentTransaction aggregated by (source Account, target Account); pair-rolling statistics; recursive walk over Transfer.Parent |
| Executives | How large is this institution's activity? Account counts, money moved, period-over-period totals. | Third-party + Business Analyst | CurrentTransaction aggregated by (period, dimension); Account counts |
Per-app stepbacks¶
L1 Reconciliation Dashboard¶
Operational integrity at the level of L1 invariants. Every sheet maps to one or more L1 SHOULD-constraints — drift, overdraft, limit breach, expected-EOD-balance, stuck pending, stuck unbundled, supersession audit. The accountant scans today's exception count, drills into the offending row, and routes it to whoever owns the upstream feed. Configured by exactly one L2 instance: feed it sasquatch_ar.yaml, get a Sasquatch dashboard; feed it cascadia.yaml, get a Cascadia dashboard.
L2 Flow Tracing¶
Operational integrity at the level of L2-declared transfer flows. Where L1 asks "is the math right?", L2 FT asks "did the transfer happen the way the institution said it would?" — every Transfer should match a declared Rail, every leg should land on the role the Rail names, every PostedRequirement should be satisfied within the declared Duration. The accountant uses it to triage failed transfers; the integration engineer uses it to validate that a newly-declared Rail actually fires.
Investigation¶
Forensic / network analysis. Where L1 and L2 FT ask integrity questions about individual transactions and transfers, Investigation steps back to accounts and the flows between them and asks pattern questions: which counterparties does this account talk to? Which pairs are moving anomalous volume relative to their baseline? What chain of transfers connects two accounts? The compliance / AML stakeholder is the primary user; the accountant reaches for it when an L1 exception pattern hints at a broader story (e.g., a single account driving multiple drift events across days).
Executives¶
Aggregate scope and scale. Steps further back than Investigation — not "are flows anomalous" but "how large is the institution". Account counts by role, transfer volume by type and period, money moved by counterparty class. The third-party stakeholder (board, regulator, executive sponsor) is the primary user; the business analyst uses it as the headline view when onboarding a new institution.
What L3 is not¶
- Not a query interface. L3 apps answer fixed question shapes; they don't let the user write arbitrary queries. Integrators who need free-form querying go to the database directly — the apps are scoped to "the questions this institution should be asking every day".
- Not customer-extended without code. Adding a sheet to a shipped app means editing the app's
app.py. L2 cannot add or hide sheets — the app structure is fixed across institutions on purpose, so training and documentation transfer cleanly between deployments. - Not where institution-specific quirks live. Quirks belong in L2 (declare a custom TransferType / Rail / role; the shipped apps will surface the resulting transactions and exceptions automatically). If a customer needs a question shape no shipped app covers, the path is "build a custom app on L1 primitives", not "fork the shipped apps".
Deliberately not in v1¶
- Scope predicates. Earlier drafts considered named groups of accounts/types for scoping L1 constraints. With Roles + per-account typed L1 fields (ExpectedEODBalance, etc.), scope predicates aren't needed in v1. Revisit if a real integrator needs to express something the typed fields can't.
- Failure category catalogue. Failure shapes (Stuck, Drift, OutOfBounds, etc.) are scenario-declaration concerns, not L2 primitive concerns; they live in a sibling document.
- Time-varying limits. Limit Schedules are time-invariant in v1. Per-day or per-window caps await a real integrator requirement.
- Cross-instance JOINs. Two L2 instances coexist via prefixing but cannot be queried together. If federated analytics across instances is needed, that's a higher-layer concern.
- Bidirectional aggregating rails. Aggregating rails whose net direction varies day-to-day are modeled as two separate Rails (one per direction). A unified bidirectional shape is deferred until the two-rail pattern proves cumbersome at scale.