recon_gen.common.l2.primitives

LAYER 2 institutional-model primitives, typed 1:1 against SPEC.md.

This module is the single source of truth for what an L2 instance contains in memory. The YAML loader (M.1.2) deserializes into these types; the validator (M.1.3) enforces the SPEC’s load-time rules on top; the SQL emitter (M.1.4) walks them; downstream apps (M.2-M.6) consume them.

Notation matches SPEC: every dataclass mirrors a SPEC primitive’s tuple shape exactly, with PascalCase types + snake_case field names. Frozen + slotted to prevent surprise mutation and typo’d attribute access.

Per F2 (M.0.13 iteration gate): Rail is a discriminated union of TwoLegRail / SingleLegRail — pyright catches “leg_role on a two-leg rail” at the construction site instead of at validation time. The aggregating-rail flags (aggregating / bundles_activity / cadence) live as optional fields on either shape, since the SPEC allows aggregating rails to be one-leg or two-leg.

Per F4: Money values are Decimal; the YAML loader (M.1.2) is responsible for the Decimal(str(value)) coercion that dodges YAML float precision.

Z.C (2026-05-15) — the legacy L2Instance.instance field has been dropped. The DB-table prefix (formerly enforced via SPEC F5’s ^[a-z][a-z0-9_]*$/30-char cap on the instance: YAML key) now lives on the cfg as cfg.db_table_prefix; the same regex/cap is enforced by common/config.py’s loader at cfg-load time. The QS-resource-ID prefix lives as cfg.deployment_name (replaces the former cfg.resource_prefix + cfg.l2_instance_prefix pair).

Per F1 + SPEC’s load-time validation list: every Role referenced by a Rail or AccountTemplate MUST resolve to either a declared Account or an AccountTemplate. This module declares the field types; the validator (M.1.3) walks the resolution graph.

Classes

Account(id, scope[, name, role, ...])

A 1-of-1 account that exists exactly once in the institution.

AccountTemplate(role, scope[, parent_role, ...])

A class of accounts that exists in many instances at runtime.

Chain(parent, children[, description])

A firing rule: one parent + one list of candidate children.

ChainChildSpec(name[, fan_in, ...])

One entry in Chain.children — name plus optional fan-in flag.

FiringsTypicalPerPeriod(period, count_range)

AF (E8): a soft per-period firing-COUNT bound on a Rail / Template.

InvestigationPersona(name, account_id, role)

A curated AML / compliance scenario actor for handbook walkthroughs.

L2Instance(accounts, account_templates, ...)

A loaded + parsed L2 institutional model.

LimitSchedule(parent_role, rail, cap[, ...])

A daily cap on per-direction flow per (parent role, rail, direction).

SingleLegRail(name, metadata_keys, leg_role, ...)

A Rail that produces one Transaction leg per firing.

TransferTemplate(name, expected_net, ...[, ...])

A multi-leg shared Transfer that bundles many Rail firings.

TwoLegRail(name, metadata_keys, source_role, ...)

A Rail that produces two Transaction legs (debit + credit) per firing.

class recon_gen.common.l2.primitives.Account(id, scope, name=None, role=None, parent_role=None, expected_eod_balance=None, description=None)[source]

Bases: object

A 1-of-1 account that exists exactly once in the institution.

Per SPEC: singletons that Rails reference by Role; the Role is technically optional but in practice required for any Account a Rail touches (per F1, enforced by the validator at load time).

description is free-form prose (markdown OK) read by handbook + training render templates per the SPEC’s “Description fields” rule. Optional at the type level but SHOULD be filled.

Parameters:
  • id (Identifier)

  • scope (Literal['internal', 'external'])

  • name (Name | None)

  • role (Identifier | None)

  • parent_role (Identifier | None)

  • expected_eod_balance (Decimal | None)

  • description (str | None)

description: str | None
expected_eod_balance: Decimal | None
id: Identifier
name: Name | None
parent_role: Identifier | None
role: Identifier | None
scope: Literal['internal', 'external']
class recon_gen.common.l2.primitives.AccountTemplate(role, scope, parent_role=None, expected_eod_balance=None, description=None, instance_id_template=None, instance_name_template=None)[source]

Bases: object

A class of accounts that exists in many instances at runtime.

Per SPEC: declares the SHAPE; the specific account instance for a given posting is selected at posting time (typically from Transaction.Metadata). parent_role MUST resolve to a singleton Account (never another AccountTemplate) — enforced by the validator at load time per the SPEC’s “singleton parent only” constraint.

instance_id_template + instance_name_template (M.4.2b) — optional Python str.format() templates the demo seed’s _materialize_instances uses when synthesizing per-template instances. Both default to None; the seed falls back to the legacy synthetic patterns ("cust-{n:03d}" for id, "Customer {n}" for name) so existing L2 fixtures don’t drift. Integrators opt in via YAML to control the persona’s per-template naming, e.g.:

instance_id_template: “cust-{n:03d}-bigfoot” instance_name_template: “Bigfoot-{n}”

Both templates support the placeholders {role} (the template’s role field) and {n} (1-indexed instance number). Loader rejects format strings that reference any other placeholder.

Parameters:
  • role (Identifier)

  • scope (Literal['internal', 'external'])

  • parent_role (Identifier | None)

  • expected_eod_balance (Decimal | None)

  • description (str | None)

  • instance_id_template (str | None)

  • instance_name_template (str | None)

description: str | None
expected_eod_balance: Decimal | None
instance_id_template: str | None
instance_name_template: str | None
parent_role: Identifier | None
role: Identifier
scope: Literal['internal', 'external']
class recon_gen.common.l2.primitives.Chain(parent, children, description=None)[source]

Bases: object

A firing rule: one parent + one list of candidate children.

Per SPEC: list cardinality carries the entire firing semantic — singleton ⇒ required (the child SHOULD fire; missing surfaces as an orphan exception); multi ⇒ XOR (exactly one of the listed children SHOULD fire per parent instance). The legacy required / xor_group flags collapse into len(children) (Z.A — locked 2026-05-13).

Aggregating rails MUST NOT appear in children (they don’t have per-Transfer parents — they sweep on cadence). Validator enforces.

AB.6 (2026-05-19): children is now tuple[ChainChildSpec, ...] — the AB.4 chain-level fan_in + expected_parent_count flags moved per-child to allow mixed-cardinality chains. Loader rejects chain-level fan_in / expected_parent_count with an actionable error pointing at the per-child shape (hard cut per AB.6.0 lock, no deprecation grace window).

Parameters:
  • parent (Identifier)

  • children (tuple[ChainChildSpec, ...])

  • description (str | None)

children: tuple[ChainChildSpec, ...]
description: str | None
parent: Identifier
class recon_gen.common.l2.primitives.ChainChildSpec(name, fan_in=False, expected_parent_count=None)[source]

Bases: object

One entry in Chain.children — name plus optional fan-in flag.

AB.6 (2026-05-19) relocated fan_in + expected_parent_count from chain-level to per-child. Motivation (per SPEC_gap_feedback §5): a single chain may carry mixed-cardinality children — some 1:1 (ACH / wire / check) AND one N:1 (batched payout) — which a single chain-level flag can’t express.

name resolves to either a Rail or a TransferTemplate (same resolution rules R5 / S4 apply per-child entry).

fan_in=True declares THIS child is N:1 — N parent firings may share one child Transfer (the batched-payout pattern). Validator requires fan_in children to resolve to TransferTemplates only.

expected_parent_count (when set) declares the exact number of parent firings per child Transfer. Set + matview flags exact-mismatch (parent count != expected). Unset + matview falls back to orphan-only detection (parent count < 2). Must be None when fan_in=False (validator C8b).

Parameters:
  • name (Identifier)

  • fan_in (bool)

  • expected_parent_count (int | None)

expected_parent_count: int | None
fan_in: bool
name: Identifier
class recon_gen.common.l2.primitives.FiringsTypicalPerPeriod(period, count_range)[source]

Bases: object

AF (E8): a soft per-period firing-COUNT bound on a Rail / Template.

The complement to AB.5’s amount_typical_range (per-firing magnitude): this bounds how MANY times the rail/template fires per period, institution-wide. The generator samples a count uniform-randomly from count_range per period when set; absent, it falls back to the per-kind firing-count heuristic. Per-firing count × per-firing amount = realistic per-period aggregates — the dashboard top-line operators scan first.

period is a bounded enum (Period); count_range is (min, max) non-negative integers with min <= max (validator W1a-c). Frozen + slotted to match the rest of the L2 primitives.

Parameters:
  • period (Literal['business_day', 'pay_period', 'week', 'month'])

  • count_range (tuple[int, int])

count_range: tuple[int, int]
period: Literal['business_day', 'pay_period', 'week', 'month']
class recon_gen.common.l2.primitives.InvestigationPersona(name, account_id, role)[source]

Bases: object

A curated AML / compliance scenario actor for handbook walkthroughs.

BXa.1 (2026-05-30): promoted from the hardcoded table that lived inside common/handbook/vocabulary.py::_sasquatch_pr_vocabulary. The handbook’s Investigation walkthroughs substitute these display names ({{ vocab.demo.investigation.layering_chain[0].name }} etc.) — curated narrative the L2 author writes, not deriveable from L2 topology. Sasquatch fixture carries 6 entries (Juniper Ridge LLC + Cascadia Trust Bank + Cascadia—Operations + Shell Company A/B/C); other operator L2s default to empty tuple and the existing {% if %} gates in the docs hide the walkthroughs that depend on the curated names.

role values currently in use: convergence_anchor, counterparty_bank, operations_account, shell_entity. Open enum — handbook template gates on specific role strings.

Parameters:
  • name (str)

  • account_id (str)

  • role (str)

account_id: str
name: str
role: str
class recon_gen.common.l2.primitives.L2Instance(accounts, account_templates, rails, transfer_templates, chains, limit_schedules, description=None, institution_name=None, institution_acronym=None, role_business_day_offsets=None, theme=None, investigation_personas=())[source]

Bases: object

A loaded + parsed L2 institutional model.

Z.C (2026-05-15) — the legacy instance field has been dropped. The DB-table prefix lives on the cfg as cfg.db_table_prefix; the QS-resource-ID prefix lives as cfg.deployment_name. Each L2 YAML is pure topology + persona + theme; the cfg yaml carries the deployment-specific identifiers.

Parameters:
account_templates: tuple[AccountTemplate, ...]
accounts: tuple[Account, ...]
chains: tuple[Chain, ...]
description: str | None
institution_acronym: str | None
institution_name: str | None
investigation_personas: tuple[InvestigationPersona, ...]
limit_schedules: tuple[LimitSchedule, ...]
rails: tuple[TwoLegRail | SingleLegRail, ...]
role_business_day_offsets: dict[str, int] | None
theme: ThemePreset | None
transfer_templates: tuple[TransferTemplate, ...]
class recon_gen.common.l2.primitives.LimitSchedule(parent_role, rail, cap, direction='Outbound', description=None)[source]

Bases: object

A daily cap on per-direction flow per (parent role, rail, direction).

Per SPEC: time-invariant in v1. The library projects each entry into the relevant StoredBalance.Limits map; L1’s Limit Breach invariant evaluates per child individually (the cap is per-child, not aggregated across siblings of the parent). Z.B (2026-05-15) renamed transfer_typerail — the field now references a Rail name directly, eliminating the templated-leg footgun where a LimitSchedule on transfer_type=<leg_rail_type> failed to fire on transactions tagged with the template’s transfer_type instead. AB.1 (2026-05-19) added direction: a single (parent_role, rail) may now carry two schedules — one Outbound (classic per-rail send cap) and one Inbound (AML / structuring threshold on inbound volume). The validator broadens uniqueness from (parent_role, rail) to (parent_role, rail, direction). Default Outbound keeps every pre-AB.1 YAML byte-equivalent.

Parameters:
  • parent_role (Identifier)

  • rail (RailName)

  • cap (Decimal)

  • direction (Literal['Outbound', 'Inbound'])

  • description (str | None)

cap: Decimal
description: str | None
direction: Literal['Outbound', 'Inbound']
parent_role: Identifier
rail: RailName
class recon_gen.common.l2.primitives.SingleLegRail(name, metadata_keys, leg_role, leg_direction, origin=None, posted_requirements=<factory>, max_pending_age=None, max_unbundled_age=None, aggregating=False, bundles_activity=<factory>, cadence=None, description=None, metadata_value_examples=<factory>, amount_typical_range=None, firings_typical_per_period=None)[source]

Bases: object

A Rail that produces one Transaction leg per firing.

Per SPEC: single-leg rails MUST be reconciled by EITHER a TransferTemplate whose leg_rails includes this rail OR an aggregating rail whose bundles_activity includes this rail’s name. A single-leg rail without either reconciliation path is a configuration error (validator catches at load).

leg_direction = Variable means the leg’s amount AND direction are determined at posting time by a containing TransferTemplate’s ExpectedNet closure requirement. Each TransferTemplate MUST contain at most one Variable-direction leg.

Per-leg Origin overrides (source_origin / destination_origin) are deliberately absent here — they only make sense on a 2-leg rail. The loader rejects them at load if they appear in YAML for a single-leg rail (hard error, per the M.1a design call).

Parameters:
  • name (Identifier)

  • metadata_keys (tuple[Identifier, ...])

  • leg_role (tuple[Identifier, ...])

  • leg_direction (Literal['Debit', 'Credit', 'Variable'])

  • origin (str | None)

  • posted_requirements (tuple[Identifier, ...])

  • max_pending_age (timedelta | None)

  • max_unbundled_age (timedelta | None)

  • aggregating (bool)

  • bundles_activity (tuple[Identifier, ...])

  • cadence (str | None)

  • description (str | None)

  • metadata_value_examples (tuple[tuple[Identifier, tuple[str, ...]], ...])

  • amount_typical_range (tuple[Decimal, Decimal] | None)

  • firings_typical_per_period (FiringsTypicalPerPeriod | None)

aggregating: bool
amount_typical_range: tuple[Decimal, Decimal] | None
bundles_activity: tuple[Identifier, ...]
cadence: str | None
description: str | None
firings_typical_per_period: FiringsTypicalPerPeriod | None
leg_direction: Literal['Debit', 'Credit', 'Variable']
leg_role: tuple[Identifier, ...]
max_pending_age: timedelta | None
max_unbundled_age: timedelta | None
metadata_keys: tuple[Identifier, ...]
metadata_value_examples: tuple[tuple[Identifier, tuple[str, ...]], ...]
name: Identifier
origin: str | None
posted_requirements: tuple[Identifier, ...]
class recon_gen.common.l2.primitives.TransferTemplate(name, expected_net, transfer_key, completion, leg_rails, leg_rail_xor_groups=(), description=None, firings_typical_per_period=None)[source]

Bases: object

A multi-leg shared Transfer that bundles many Rail firings.

Per SPEC: every firing of a leg_rails rail with the same transfer_key Metadata values posts to the same shared Transfer. L1 Conservation flags the Transfer if its legs don’t sum to expected_net; L1 Timeliness flags any leg that posts after the derived Transfer.Completion.

A Rail listed in leg_rails MUST NOT also fire standalone Transfers — its firings always join the shared Transfer matching the transfer_key values.

leg_rail_xor_groups (AB.3) declares mutually-exclusive subsets of leg_rails — exactly one member of each inner tuple SHOULD fire per Transfer. Empty default keeps every pre-AB.3 template byte- equivalent; the structural validator (C1a-d) enforces members ⊆ leg_rails, members are Variable-direction, no overlap between groups, ≥2 members per group. Runtime “exactly one fires” check lives in the _xor_group_violation matview (AB.3.3).

Parameters:
  • name (Identifier)

  • expected_net (Decimal)

  • transfer_key (tuple[Identifier, ...])

  • completion (str)

  • leg_rails (tuple[Identifier, ...])

  • leg_rail_xor_groups (tuple[tuple[Identifier, ...], ...])

  • description (str | None)

  • firings_typical_per_period (FiringsTypicalPerPeriod | None)

completion: str
description: str | None
expected_net: Decimal
firings_typical_per_period: FiringsTypicalPerPeriod | None
leg_rail_xor_groups: tuple[tuple[Identifier, ...], ...]
leg_rails: tuple[Identifier, ...]
name: Identifier
transfer_key: tuple[Identifier, ...]
class recon_gen.common.l2.primitives.TwoLegRail(name, metadata_keys, source_role, destination_role, origin=None, source_origin=None, destination_origin=None, expected_net=None, posted_requirements=<factory>, max_pending_age=None, max_unbundled_age=None, aggregating=False, bundles_activity=<factory>, cadence=None, description=None, metadata_value_examples=<factory>, amount_typical_range=None, firings_typical_per_period=None)[source]

Bases: object

A Rail that produces two Transaction legs (debit + credit) per firing.

When fired as a standalone Transfer, expected_net MUST be set (typically 0); L1 Conservation enforces Σ legs = expected_net. When the rail is a leg-pattern of a TransferTemplate, expected_net MUST be unset — the template owns the bundle’s ExpectedNet. Per F3 this is a cross-entity validation rule (the validator’s pass 2).

Per-leg Origin: origin shorthands “both legs share this Origin”; source_origin / destination_origin override per leg when the legs differ (e.g., the leg touching an external counterparty is ExternalForcePosted while the internal counterpart is InternalInitiated). The validator (rule O1) checks every leg resolves to an Origin under the SPEC’s resolution table.

PostedRequirements / aging: posted_requirements adds Rail-specific fields beyond the auto-derived TransferKey + chain-Required-true parent_transfer_id (see derived.posted_requirements_for); max_pending_age + max_unbundled_age are aging-watch durations.

Parameters:
  • name (Identifier)

  • metadata_keys (tuple[Identifier, ...])

  • source_role (tuple[Identifier, ...])

  • destination_role (tuple[Identifier, ...])

  • origin (str | None)

  • source_origin (str | None)

  • destination_origin (str | None)

  • expected_net (Decimal | None)

  • posted_requirements (tuple[Identifier, ...])

  • max_pending_age (timedelta | None)

  • max_unbundled_age (timedelta | None)

  • aggregating (bool)

  • bundles_activity (tuple[Identifier, ...])

  • cadence (str | None)

  • description (str | None)

  • metadata_value_examples (tuple[tuple[Identifier, tuple[str, ...]], ...])

  • amount_typical_range (tuple[Decimal, Decimal] | None)

  • firings_typical_per_period (FiringsTypicalPerPeriod | None)

aggregating: bool
amount_typical_range: tuple[Decimal, Decimal] | None
bundles_activity: tuple[Identifier, ...]
cadence: str | None
description: str | None
destination_origin: str | None
destination_role: tuple[Identifier, ...]
expected_net: Decimal | None
firings_typical_per_period: FiringsTypicalPerPeriod | None
max_pending_age: timedelta | None
max_unbundled_age: timedelta | None
metadata_keys: tuple[Identifier, ...]
metadata_value_examples: tuple[tuple[Identifier, tuple[str, ...]], ...]
name: Identifier
origin: str | None
posted_requirements: tuple[Identifier, ...]
source_origin: str | None
source_role: tuple[Identifier, ...]