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:
Reads transactions, not balances — every prior promotion (DriftGenerator, OverdraftGenerator, ExpectedEodBalanceGenerator) emits daily_balances rows. StuckPendingGenerator emits a Pending transaction with no balance row.
Couples to the L2 instance shape — scenario_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.
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
|
Emit a single Pending transaction whose posting is in the past of as_of by max_pending_age_seconds + overshoot_seconds. |
|
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:
objectEmit 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:
objectStuck-Pending detector. The matview gates on
status = 'Pending'ANDage_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)
- 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:
- Parameters:
rail_name (str)
as_of (datetime)
overshoot_seconds (int)
account_role (str)
instance (L2Instance | None)
account_id (str | None)