An iframe can reconnect successfully while its original wallet operation remains unresolved. Test recovery by renewing the session, looking up the original operation and checking the wallet effect. If the first debit committed before its reply disappeared, a fresh session must recover that decision without creating another debit. A reconnect that merely makes the screen responsive can hide the wrong economic result.
This article was prepared with AI-generated research, prose and code. The local test results are recorded below.
Cover: Original technical artwork showing the synthetic reconnect contract.
The example is a small, executable Python model. A fake wallet retains a decision, the reply is deliberately lost and the first session expires. Seven tests distinguish accepted and rejected outcomes from unavailable evidence. They exercise application behavior in one process. They do not run a browser, contact a sportsbook or place a wager.
Give the session and the operation different lifetimes
The session answers whether this caller can currently access the player's operation. The operation identifies the wallet instruction whose result the caller needs. Renewing access changes the session token. It does not change which instruction was previously submitted. Preserve that separation in the reconnect path, even if both identifiers happen to be strings in your adapter.
For this fixture, player alice places ticket-7 using operation-1. The stake is 300 USD minor units against a starting balance of 1000. An accepted operation leaves 700. These are invented values chosen to make duplicate effects visible. They are not a price, customer balance or measurement from a deployed platform.
The immutable intent contains player, operation identifier, ticket, stake and currency. This model treats the ticket as a complete reference to the requested bet. It does not model prices, market suspension or changing bet terms. A production contract would need to define those additional inputs and their versioning before deciding what counts as the same intent.
A temporary session is deliberately absent from that intent. session-1 permits the initial call. Issuing session-2 for the same player makes the first token expire in the fake registry. The original intent still identifies operation-1. Keeping a session token inside the operation key would make access renewal look like authorization for a different wallet change.
Keep the test's meaning narrow: ACCEPTED means the fake wallet accepted this debit. It does not prove that a sportsbook persisted bet acceptance, that settlement completed or that the player's displayed balance is current. Those are separate records and require their own acceptance conditions. A wallet response cannot silently stand in for the rest of the bet lifecycle.
There is one pending intent per frame object in this example. The test calls place() once before recovery and uses fixed positive stakes with one currency. The small runtime does not validate arbitrary user input or prevent another caller from invoking place() again. A real controller needs a defined admission rule for new instructions while an earlier one remains unresolved. The fixture isolates reconnect so that this separate controller problem does not disappear into the demonstration.
Lose the reply after retaining the wallet decision
Fault placement determines what the test can establish. A failure before submission demonstrates that nothing reached the fake wallet. Here, submit() first records its decision and any debit, then raises LostReply. The frame catches that exception and returns UNKNOWN. The caller has lost knowledge of the result while the wallet still holds the result.
The accepted path starts with balance 1000, applies the 300 debit and records one effect. The frame sees no accepted response. Next, the registry issues a replacement session. The original token becomes invalid, so trying to recover through the expired token raises SessionExpired. Renewed access is necessary, but the operation being recovered is unchanged.
In the deliberate defect, BuggyFrame.reconnect() derives a replacement operation from the new session and submits the original ticket again. The wallet sees a different operation identifier with sufficient funds, so it accepts another debit. Its duplicate protection has not malfunctioned: the caller described a new operation to it.
An executed demonstration makes the difference visible. The buggy reconnect returns session-2:replacement, leaves balance 400 and records two effects. The corrected reconnect returns operation-1, leaves balance 700 and records one effect. These are deterministic outputs of the local model. No network latency, throughput improvement or production incident frequency was measured.
The first failing assertion checks the returned operation identifier. A plausible accepted response is insufficient: it must belong to the original intent.
Balance and effect count are also part of the test contract. The separate demonstration reaches those observations on the broken path even though the earlier assertion stops that particular failing test.
Recover a result through a read
Original diagram of the executed synthetic model. Amounts are invented minor-unit values, not production data.
Corrected Frame.reconnect() updates the session used for access and calls wallet.lookup(new_session, self.intent). It does not call submit(). Lookup checks the current session against the intent's player, finds the retained operation and compares the complete intent before returning its stored outcome. Repeating that read leaves the balance and effect journal unchanged.
For an accepted result, the recovery response must identify the same operation and retain the same outcome. This fixture returns accepted:operation-1. It does not invent a new receipt simply because the connection changed. A real adapter should preserve the provider's meaningful result identifiers and distinguish a stored decision from a new attempt to obtain that decision.
The distinction also appears in other API contracts. Stripe describes its API v1 behavior in its idempotent request documentation:
For API v1, Stripe saves the status code and body of the first request made with a given idempotency key after endpoint execution begins.
That is a Stripe-specific replay contract. It illustrates why a retained decision matters, but it does not establish an operator wallet's lookup endpoint, retention period or response semantics. This article's wallet contract is declared in the runnable model. Its recovery operation is a read, while its submission path recognizes repeated operation identifiers and checks their intent.
If the real wallet offers only resubmission with the same key, its documented duplicate-handling contract needs separate verification. Do not mechanically rename that call lookup. Establish which inputs must remain identical, what a concurrent repeat does and whether the original result remains available after session renewal. The correct adapter follows the actual interface rather than assuming another provider's behavior.
Agree on the lookup response before wiring it into the screen. An accepted response should identify the original operation and the decision being recovered. A rejection should carry enough stable meaning for the caller to report that decision without rerunning eligibility. An unresolved response should distinguish unavailable evidence from a known terminal result. The model compresses both unresolved situations to UNKNOWN, while its separate tests retain the reason for each fault.
In a deployed integration, preserve that reason in an internal recovery record even if the player sees one pending message. An operator investigating a failed lookup needs a different next step from an operator investigating a missing retained record. Exposing this distinction internally does not require displaying provider internals or credentials in the user interface.
A retained rejection must survive a changed balance
The rejection test begins with only 100 minor units available for a 300 stake. The fake wallet records REJECTED and applies no debit, then loses the reply. Before reconnect, the test replenishes the balance to 1000. This change is intentional: it makes reevaluation capable of producing a different decision.
Recovery must still return the rejection for operation-1. The corrected frame leaves the replenished balance at 1000 with zero effects. It reads what happened to that operation at its original decision point. It does not ask whether the same ticket might be affordable under today's conditions. A rejection is retained evidence rather than an invitation to rerun a business decision.
The buggy demonstration does the latter. By submitting session-2:replacement, it obtains an accepted outcome and leaves balance 700 after one effect. A test checking only that no duplicate debit occurred would miss this error: the original request was rejected, so there was no first debit to duplicate. The mistake is changing the player's intended operation during recovery.
This is why the acceptance condition needs an outcome identity as well as an effect count. One debit can be wrong when recovery was supposed to report a rejection. In a real product, a later attempt may be valid after the user receives the rejection and explicitly chooses to try again. That is a new instruction with its own authorization and terms.
The model does not decide how a user interface should request that instruction. It supplies a useful boundary for the UI: recovering an unresolved operation and initiating a newly authorized one need distinct paths. A reconnect handler must not take the second path merely because it has regained access to the wallet.
Keep unknown results unresolved
Two tests prevent failed reads from becoming business commands. One disables lookup while preserving the retained decision. The other clears the in-memory journal after the original debit. Both start with a lost accepted reply. Both require UNKNOWN, balance 700 and one effect after reconnect. Neither permits a replacement operation.
These faults represent different evidence problems. An unavailable lookup means the reader cannot obtain a result now. A missing journal entry means this lookup found no retained decision in the model. It does not establish that the debit never happened. The effect list and balance deliberately survive the injected journal loss, exposing that dangerous inference.
Clearing a dictionary is a controlled fault injection, not a storage recovery experiment. There is no disk, replica or crash in this fixture. Its claim is limited to how the frame behaves when the outcome source cannot answer. Durable storage design and the atomic relationship between real ledger effects and retained decisions remain separate work.
Production recovery needs a defined route for this uncertainty. Record the original operation, the evidence available and the owner responsible for resolving it. A later read may establish an accepted or rejected decision. If it cannot, an authorized reconciliation procedure may need another authoritative record. The frame should preserve the unresolved intent rather than manufacture certainty with a fresh identifier.
Do not use the absence of an exception as your oracle. An implementation can swallow lookup failure and return a generic success screen while issuing another debit underneath. Inspect the observable wallet effect and the identity attached to the response. In the missing-journal test, the journal must also remain empty: reconnect must not silently recreate it through submission.
A retention boundary deserves the same care. This example keeps decisions for the life of its in-memory wallet and makes no promise about an elapsed lookup window. Before applying the pattern elsewhere, find out whether operation results can expire while ledger entries remain available. A wallet may need another reference for investigation after that point. Generating a replacement operation cannot restore a pruned response or establish whether an earlier debit exists.
Likewise, a pending decision is different from an absent decision. The fixture records only immediate acceptance or rejection, so it has no asynchronous pending branch. If the real wallet returns pending, define what may transition that operation and what the reconnect read can establish at each stage. Do not add a timer that converts pending into permission to create a second instruction without a documented business rule.
| Reconnect evidence | Required result in this model | Additional debit |
|---|---|---|
| Retained accepted operation | Original accepted outcome | None |
| Retained rejected operation | Original rejected outcome | None |
| Lookup unavailable | UNKNOWN | None |
| Original journal entry missing | UNKNOWN | None |
| Session belongs to another player | WrongPlayer | None |
| Original session expired | SessionExpired | None |
Run the same contract against both implementations
The fixture uses Python 3.14.7 and the standard library. Save the two complete files below as recovery.py and test_recovery.py in one directory. The runtime contains the deliberately broken frame and the corrected subclass. The test suite selects the implementation through FRAME_VARIANT, so changing implementations does not change the expected behavior.
Run these commands from that directory:
FRAME_VARIANT=BuggyFrame python3 -m unittest -v test_recovery FRAME_VARIANT=Frame python3 -m unittest -v test_recovery The recorded RED run executed seven tests and failed four. The accepted-result, retained-rejection, unavailable-lookup and missing-journal cases failed. Expired-session rejection, wrong-player rejection and conflicting-intent rejection already passed on the broken implementation. Those passing checks matter: the defect survives basic access checks and an operation-key conflict check.
After correction, the same seven tests passed without changing their assertions. The fix adds a reconnect method that performs the original-outcome lookup. This establishes the specified sequential model. Seven tests cannot establish general wallet correctness. The exact test file remained unchanged between the reviewed RED and GREEN runs.
Python's unittest documentation explains the test runner and exception assertions. The dataclasses documentation covers the frozen records and replace() used here. Frozen records make accidental mutation harder in this example. They do not provide persistence, authentication or a transaction boundary around the wallet's dictionaries.
Complete runtime: fake wallet and both reconnect implementations
"""Synthetic in-memory wallet; no network, browser, persistence or real auth.""" from dataclasses import dataclass, replace UNKNOWN = 'UNKNOWN' class SessionExpired(Exception): pass class WrongPlayer(Exception): pass class IntentConflict(Exception): pass class LostReply(Exception): pass @dataclass(frozen=True) class Intent: player: str operation_id: str ticket: str stake_minor: int currency: str @dataclass(frozen=True) class Outcome: operation_id: str status: str response: str class SessionRegistry: def __init__(self): self.active = {} self.owners = {} self.sequence = 0 def issue(self, player): self.sequence += 1 token = f'session-{self.sequence}' self.active[player] = token self.owners[token] = player return token def check(self, token, player): owner = self.owners.get(token) if owner is None or self.active.get(owner) != token: raise SessionExpired(token) if owner != player: raise WrongPlayer(player) class Wallet: def __init__(self, sessions, balances): self.sessions = sessions self.balances = dict(balances) self.journal = {} self.effects = [] self.lookup_available = True def submit(self, session, intent, lose_reply=False): self.sessions.check(session, intent.player) retained = self.journal.get(intent.operation_id) if retained is not None: canonical, outcome = retained if canonical != intent: raise IntentConflict(intent.operation_id) else: accepted = self.balances[intent.player] >= intent.stake_minor status = 'ACCEPTED' if accepted else 'REJECTED' outcome = Outcome(intent.operation_id, status, f'{status.lower()}:{intent.operation_id}') if accepted: self.balances[intent.player] -= intent.stake_minor self.effects.append(intent) self.journal[intent.operation_id] = (intent, outcome) if lose_reply: raise LostReply(intent.operation_id) return outcome def lookup(self, session, intent): self.sessions.check(session, intent.player) if not self.lookup_available: return UNKNOWN retained = self.journal.get(intent.operation_id) if retained is None: return UNKNOWN canonical, outcome = retained if canonical != intent: raise IntentConflict(intent.operation_id) return outcome class BuggyFrame: def __init__(self, wallet, session): self.wallet, self.session = wallet, session self.intent = None def place(self, intent, lose_reply=False): self.intent = intent try: return self.wallet.submit(self.session, intent, lose_reply=lose_reply) except LostReply: return UNKNOWN def reconnect(self, new_session): self.wallet.sessions.check(new_session, self.intent.player) self.session = new_session replacement = replace(self.intent, operation_id=f'{new_session}:replacement') return self.wallet.submit(new_session, replacement) class Frame(BuggyFrame): def reconnect(self, new_session): self.session = new_session return self.wallet.lookup(new_session, self.intent) Complete unchanged test suite
"""Seven observable requirements; identical suite for buggy and fixed frames.""" import os import unittest from dataclasses import replace from recovery import ( UNKNOWN, BuggyFrame, Intent, IntentConflict, SessionExpired, SessionRegistry, Wallet, WrongPlayer, ) class RecoveryContract(unittest.TestCase): def setUp(self): import recovery self.frame_type = getattr(recovery, os.environ.get('FRAME_VARIANT', 'BuggyFrame')) self.sessions = SessionRegistry() self.old = self.sessions.issue('alice') self.wallet = Wallet(self.sessions, {'alice': 1000, 'bob': 1000}) self.intent = Intent('alice', 'operation-1', 'ticket-7', 300, 'USD') self.frame = self.frame_type(self.wallet, self.old) def lose_reply(self): self.assertEqual(self.frame.place(self.intent, lose_reply=True), UNKNOWN) return self.sessions.issue('alice') def test_accepted_lost_reply_recovers_original_outcome_once(self): new = self.lose_reply() result = self.frame.reconnect(new) self.assertEqual(result.operation_id, self.intent.operation_id) self.assertEqual(result.status, 'ACCEPTED') self.assertEqual(result.response, 'accepted:operation-1') self.assertEqual(self.wallet.balances['alice'], 700) self.assertEqual(len(self.wallet.effects), 1) self.assertEqual(self.frame.reconnect(new), result) self.assertEqual(self.wallet.balances['alice'], 700) self.assertEqual(len(self.wallet.effects), 1) def test_retained_rejection_survives_replenishment(self): self.wallet.balances['alice'] = 100 new = self.lose_reply() self.wallet.balances['alice'] = 1000 result = self.frame.reconnect(new) self.assertEqual(result.operation_id, 'operation-1') self.assertEqual(result.status, 'REJECTED') self.assertEqual(result.response, 'rejected:operation-1') self.assertEqual(self.wallet.balances['alice'], 1000) self.assertEqual(self.wallet.effects, []) def test_unavailable_lookup_remains_unknown(self): new = self.lose_reply() self.wallet.lookup_available = False self.assertEqual(self.frame.reconnect(new), UNKNOWN) self.assertEqual(self.wallet.balances['alice'], 700) self.assertEqual(len(self.wallet.effects), 1) def test_missing_journal_does_not_create_operation(self): new = self.lose_reply() self.wallet.journal.clear() # deterministic fault injection, not a real store self.assertEqual(self.frame.reconnect(new), UNKNOWN) self.assertEqual(self.wallet.balances['alice'], 700) self.assertEqual(len(self.wallet.effects), 1) self.assertEqual(self.wallet.journal, {}) def te...
