recon_gen.common.spine.stuck_pending

Stuck-Pending family — first transaction-based + instance-coupled spine invariant.

StuckPendingInvariant fires when a Pending transaction’s posting age exceeds the rail’s configured max_pending_age. This is the first spine invariant that:

  1. Reads transactions, not balances — every prior promotion (DriftGenerator, OverdraftGenerator, ExpectedEodBalanceGenerator) emits daily_balances rows. StuckPendingGenerator emits a Pending transaction with no balance row.

  2. Couples to the L2 instance shapescenario_for(rail_name) reads the rail’s max_pending_age from L2 to plant a transaction whose age overshoots it. The shape-resolution discipline AS.2’s DriftInvariant.scenario_for(role) started extends here: the invariant owns L2 resolution, fails loud at the request site.

  3. Uses wall-clock time — the matview computes age_seconds = CURRENT_TIMESTAMP - posting. The plant computes posting via datetime.now() - (max_pending_age + overshoot) so that at refresh time, age_seconds > max_pending_age_seconds → fires. Identity is (transaction_id, rail_name) — stable across refreshes; age value itself is NOT in the identity (it drifts with wall-clock).

Per AU.0/AU.2 lessons: the empirical edge check is mandatory. Prediction (written before the test): stuck_pending trips ONLY itself. Pending transactions don’t contribute to drift’s computed_subledger_balance (filters status=’Posted’); no balance row emitted ⇒ no overdraft, no expected_eod, no ledger_drift, no drift. Single-edge registry entry expected. The test verifies empirically — if it surfaces an unexpected edge, the AU.x cadence’s stop-and-evaluate catches it.

overshoot_seconds=0 is the non-violating shape (age == threshold; matview’s > filter excludes). Same AP.2 convention adapted to the seconds-unit knob.

Classes

StuckPendingGenerator(transaction_id, ...[, ...])

Emit a single Pending transaction whose posting is in the past of as_of by max_pending_age_seconds + overshoot_seconds.

StuckPendingInvariant([prefix])

Stuck-Pending detector.

class recon_gen.common.spine.stuck_pending.StuckPendingGenerator(transaction_id, transfer_id, rail_name, account_id, account_role, account_parent_role, max_pending_age_seconds, overshoot_seconds, as_of, prefix='spec_example')[source]

Bases: object

Emit a single Pending transaction whose posting is in the past of as_of by max_pending_age_seconds + overshoot_seconds. NO balance row, NO related Posted transactions — the stuck_pending matview reads only the transactions table, so the plant is single-row.

Post-AW.5: as_of is the owned temporal frame (matches what the matview reads from <prefix>_config.as_of). NO datetime.now() — plant + matview both read from one source; tests are deterministic; no TZ skew to absorb.

Parameters:
  • transaction_id (str)

  • transfer_id (str)

  • rail_name (str)

  • account_id (str)

  • account_role (str)

  • account_parent_role (str | None)

  • max_pending_age_seconds (int)

  • overshoot_seconds (int)

  • as_of (datetime)

  • prefix (str)

account_id: str
account_parent_role: str | None
account_role: str
as_of: datetime
property claimed_accounts: frozenset[str]

The single account_id this plant stucks a Pending tx on. AV.5.

emit(conn, *, scenario_id=None)[source]
Return type:

None

Parameters:
  • conn (SyncConnection)

  • scenario_id (str | None)

property intended: RuleViolation
max_pending_age_seconds: int
overshoot_seconds: int
prefix: str = 'spec_example'
rail_name: str
transaction_id: str
transfer_id: str
class recon_gen.common.spine.stuck_pending.StuckPendingInvariant(prefix='spec_example')[source]

Bases: object

Stuck-Pending detector. The matview gates on status = 'Pending' AND age_seconds > max_pending_age_seconds (per-rail cap from L2). Identity is (transaction_id, rail_name)transaction_id alone is PK-unique, but rail_name rounds out the analyst-facing identity for diff readability.

Parameters:

prefix (str)

detect(conn)[source]
Return type:

set[Violation]

Parameters:

conn (SyncConnection)

name: ClassVar[str] = 'stuck_pending'
prefix: str = 'spec_example'
scenario_for(rail_name, *, as_of, overshoot_seconds=60, account_role='CustomerSubledger', instance=None, account_id=None)[source]

Resolve rail_name against the shape; return a generator that plants a Pending transaction on a account_role account with posting = as_of − (rail.max_pending_age + overshoot).

as_of is the owned temporal frame the matview reads from <prefix>_config.as_of (per AW.2). Caller is responsible for ensuring config.as_of matches — see common.l2.config_table.set_as_of. Tests pass a pinned datetime; production passes datetime.now() at scenario-creation time (same value the refresh helper UPDATEs into config).

overshoot_seconds=0 ⇒ age == threshold ⇒ matview’s > filter excludes ⇒ no fire (AP.2 non-violating convention adapted). Positive overshoot fires loud; negative also non-violating (age < threshold). Post-AW.5 the overshoots can be small natural values — no wall-clock skew to absorb.

Raises ValueError if the L2 has no rail with rail_name, OR if that rail has no max_pending_age set (stuck_pending’s matview filter excludes rails without one — manufacturing a scenario against an uncovered rail would silently emit an inert row, which we refuse).

AY.4.c — account_id overrides the default synthetic ID. The plant adapter (AY.4.c.3) threads OLD StuckPendingPlant.account_id through this kwarg so N plants on the same rail produce N distinct generators (the default f”acct-stuck-pending-{rail_name}” derivation would collide). Existing test callers can pass nothing → preserves the synthetic default byte-stable.

Return type:

StuckPendingGenerator

Parameters:
  • rail_name (str)

  • as_of (datetime)

  • overshoot_seconds (int)

  • account_role (str)

  • instance (L2Instance | None)

  • account_id (str | None)