Changing the default KYC provider should change where new applications go. It should not silently change who owns applications already waiting for a result. Test the cutover by creating a pending application with provider A, switching new admissions to B and then delivering A's delayed callback. The original application must still resolve through its recorded assignment.
This article was prepared with AI-generated research, prose and code. The local test results are recorded below.
The example below uses fictional providers and application identifiers in a small Python model. It separates the routing configuration, the assignment recorded when an application opens and the later callback decision. No real identity documents, provider credentials or customer records are used. The model tests callback ownership and application transitions. It does not determine whether an applicant should pass a real identity check.
Cover and assignment trace: original synthetic illustrations prepared for this article.
Record ownership when the application opens
A route answers where the next application should be sent. An assignment answers which provider received a particular application. Those questions can have different answers immediately after a cutover. A global configuration value is enough to choose an initial destination, but it is insufficient evidence for interpreting a callback about work that began under an earlier configuration.
Retain the application identifier, assigned provider and provider-side case reference together. Also retain the routing revision used for that assignment. The revision explains why the application went to A even though today's route selects B. It is provenance for the admission decision, not a substitute for the provider's identity or case reference. A revision number alone cannot join a delayed result to an application.
The fixture stores these values when creating each application. A provider switch changes the default and increments its revision. Existing application records retain their original values. This is a declared model contract, not a claim that every KYC provider exposes the same identifiers or lifecycle. An actual adapter must map its documented provider objects into an assignment record with equivalent meaning.
Do not attach ownership only to a person. One person may have multiple applications, document submissions or rechecks over time. The event being processed must identify the particular verification work whose outcome it describes. Collapsing everything into the person's latest provider can attach an older decision to the wrong application even when the person's internal identifier remains unchanged.
The example deliberately leaves reassignment out of the cutover operation. If an organization needs to move an existing application to another provider, that is additional behavior with its own lineage. Specify how the old case is closed or retained, how the replacement case is identified and which later callbacks may affect either record. Changing the default route should not perform that migration implicitly.
Synthetic sequential trace from the executed local demo. It does not authenticate provider callbacks.
Reproduce the delayed-callback failure
Begin with one pending application assigned to A. Switch the default to B without completing the original application. Then deliver a terminal callback from A for its original case. That sequence is the discriminating test: a receiver that consults only today's default can reject a legitimate old-provider result. A successful B callback for a newly created application would not expose this defect.
The deliberate broken implementation uses the current route when evaluating ownership. Its error is a choice of authority, not a malformed message or an unavailable provider. The callback can carry the correct application and case identifiers and still be rejected because the receiver asks which provider is current instead of which provider owns this application.
Also test the opposite direction. A B-labeled callback must not acquire authority over an old A application simply because the default now selects B. A cutover that only rejects legitimate late results creates an operational backlog. One that accepts a result from the wrong owner can change the wrong application's state. Both outcomes belong in the ownership contract.
Keep all other conditions stable during this test. Do not simultaneously change the application identifier, signature key, provider case reference and expected outcome. Otherwise, a rejected event would not reveal which boundary caused the rejection. This fixture uses trusted synthetic event objects and holds transport authentication outside its scope so the assignment decision can be inspected directly.
A useful failure report shows the assigned provider and routing revision beside the current default. Include the callback's provider and case reference as separate fields. That small comparison makes the defect visible without relying on a screenshot of an applicant dashboard. The application can remain pending while the event journal explains that the receiver selected the wrong ownership rule.
Match the callback to the retained assignment
The corrected receiver checks the callback against the assignment saved for that application. It does not rewrite the assignment to agree with the new default. The application identifier selects the local record; provider and case reference must then match the provider work associated with that record. A callback naming an unknown application or an unmatched pair is held as an exception.
In this model, quarantining an event means recording why it was not allowed to change the application. It is not evidence that the sender was malicious. A bad mapping, an incorrect environment or an incomplete import could produce the same mismatch. Preserve the observation and leave its cause open until the relevant source records establish what happened.
A provider-supplied application field is not trusted authentication evidence. The fixture's provider label is an input used to test routing. Production authentication must establish the sender and bind the admitted message to that identity before ownership logic runs. Do not pass an arbitrary payload string directly into the model and describe the resulting comparison as verification of the provider.
The provider case reference also needs a defined namespace. A string that is unique inside one provider account may not be unique across environments or multiple accounts. This example uses one logical account per fictional provider. An actual integration should include the account and environment boundaries required by its provider contract, rather than assuming a globally unique case identifier.
Once the ownership match succeeds, the receiver may evaluate the proposed application transition. Keep that evaluation separate from admission of the message. An event can be well formed and attributed to the expected provider yet still conflict with an already recorded terminal decision. Correct ownership is necessary for an update; it does not make every proposed update valid.
Handle duplicate deliveries without replaying the transition
A callback delivery and an application outcome have different identities. The same provider event can arrive repeatedly, while multiple distinct provider events can refer to one application. Deduplicating only by application would discard legitimate later observations. Deduplicating only by a globally unscoped event string can mix unrelated providers. The model scopes an event identifier to its provider.
Stripe's official webhook delivery guidance states:
Stripe doesn’t guarantee the delivery of events in the order that they’re generated.
That statement describes Stripe's delivery behavior. It is not a promise about a KYC provider. Its role here is to explain why arrival order should not be treated as proof of business ownership or lifecycle order. The proposed KYC receiver has its own explicit rules, and a real provider integration must verify those rules against its own documentation.
For an identical repeated event, return a duplicate disposition and apply no additional application transition. Retain the original decision record. The test should compare the resulting application state and the number of recorded effects, rather than merely checking that the second call returned successfully. A receiver can acknowledge a delivery while accidentally repeating the state change underneath.
The event's semantic payload matters too. Reusing an event identifier with a changed outcome is a conflict, even if both messages refer to the same application. Preserve the first observed payload and refuse to replace its meaning silently. The fixture names this situation EVENT_CONFLICT. It does not decide whether the conflict arose from a provider defect, corrupted storage or an untrusted sender.
A production implementation must define which fields participate in that comparison. A redelivery timestamp or transport trace might change without changing the business event. Conversely, omitting the case reference or decision from the compared fields could hide a material change. This model compares its small immutable event object. It is not a canonicalization specification for arbitrary provider JSON.
Preserve terminal decisions when later evidence conflicts
An accepted callback can take a pending application to a terminal state in this model. A later, different event proposing the opposite terminal result must not simply overwrite it. The receiver records TERMINAL_CONFLICT and retains the original state. This rule makes conflicting evidence visible instead of allowing whichever message arrived last to define the application's history.
That is a deliberately narrow lifecycle. Real verification platforms may support reopened cases, amended decisions or review stages that change the permitted transition graph. If such a transition exists, represent it explicitly with its required authority and evidence. A general last-write-wins update should not stand in for a provider's amendment procedure or an operator's review decision.
Distinguish two forms of conflict in the acceptance record. EVENT_CONFLICT concerns an identifier being reused with changed event content. TERMINAL_CONFLICT concerns a new event proposing a transition that the retained application state does not permit. They can require different investigation paths. Merging them into one generic callback error would conceal which identity or state boundary needs attention.
The tests do not infer correctness from a terminal label alone. They check that a rejected contradictory event leaves the original decision and effect count intact. Otherwise, a receiver could record a conflict message after it had already overwritten the application. The journal and resulting state must agree about whether the event was allowed to produce a transition.
Keep exceptions available to the team responsible for pending work after cutover. Removing a provider from the new-admission route does not resolve its open cases. The old integration may still need credentials, callback admission and operational access for that declared period. This article does not prescribe a retention duration or authorize changes to those real controls.
Separate route history from application history
A cutover event explains a change in configuration. A callback decision explains how a particular message affected an application. Store those histories as different records. Otherwise, an audit view can imply that every pending application changed provider when only the default route changed. An application outcome should not be inferred from a route revision appearing in a timeline.
The local model retains a route journal and a callback journal. A callback record names its disposition, while the application retains its assignment and state. The distinction allows a reviewer to reconstruct the relevant sequence without treating a rejected message as a successful verification. Recording an event is not the same action as applying its proposed outcome.
For a cutover packet, identify the population of applications that remained with A. Record the assignment references needed to inspect each pending case. Separately identify applications admitted under B. A total count of pending work is insufficient when the operator needs to know which integration must still answer a case lookup or deliver a result.
Do not put identity documents or live access tokens into a portable test report. Synthetic identifiers are sufficient for the executable example. A production investigation needs appropriate access and data-handling controls for its original records, with a redacted packet that preserves the necessary joins. Removing the only provider case reference may make the report safer to distribute but impossible to use.
The journal is an in-memory list here. It is useful for checking which decisions the receiver exposes, but it is neither tamper evident nor durable. A real implementation needs a defined relationship between its state update and decision record. This fixture does not test a crash between those writes or establish that two workers cannot process the same callback concurrently.
Define what acknowledgment means at the adapter boundary
The model returns a disposition to its caller. It does not return an HTTP response or tell a provider to stop retrying. Keep those contracts separate when implementing the real adapter. Decide which processing evidence must exist before acknowledgment, which failures remain retryable and where an accepted but quarantined message can be investigated. Returning an HTTP success code alone would reveal none of those decisions.
Consider an authenticated event that names an unknown local application. The fixture records QUARANTINED and preserves the existing application states. Its first observation is also retained in the event registry. An identical redelivery therefore returns DUPLICATE, even though the original delivery never applied an outcome. In this model, duplicate means the event has been seen; it does not mean the application was verified. Inspect the original journal entry when deciding what happened.
That behavior deliberately prevents ordinary redelivery from becoming an implicit repair mechanism. If the missing mapping is later corrected, replaying the same event through the ordinary callback method will still find its first observation. An operator needs a separately specified reprocessing action with its own authority and record. This fixture does not implement that action. It would be misleading to describe the event registry as a complete recovery queue.
A distinct event reporting the same terminal outcome has another disposition: TERMINAL_UNCHANGED. The message adds an observation without adding an application transition. By contrast, the same event identifier with changed content is EVENT_CONFLICT. Keep those distinctions in an adapter's response mapping; a generic already processed label would hide whether the receiver saw a repeated delivery, corroborating evidence or contradictory content.
The input domain is narrow too. Only VERIFIED and REJECTED outcomes enter this callback decision procedure. Another outcome string raises ValueError before a journal entry is added. A real adapter may need to handle pending reviews, expired cases or additional document requests. Define their mapping before extending this model. Do not relabel an unsupported provider status as rejection merely to fit the example's terminal-state vocabulary.
Identify the write boundary before adding multiple workers
The example performs its operations sequentially in one process. That makes the ownership defect observable without relying on a scheduling race. It also means the passing duplicate test does not prove that two workers cannot both observe an unseen event. A deployed receiver needs a write boundary that preserves event admission and the permitted application effect under its actual concurrency model.
Write down the interruption points before choosing a mechanism. If an event is marked seen before its application update becomes durable, a restart could suppress work that never committed. If the application update becomes durable before the event identity is retained, redelivery could revisit the update without its original decision. These are proposed failure scenarios to test at the storage boundary, not failures exercised by this in-memory suite.
Decide what must commit together and what can be recovered afterward. An application transition, its attributable event and any separately delivered downstream notification may have different storage boundaries. A database transaction can only establish the guarantees of the operations it actually covers. The local effects list records transitions; it does not send notifications or demonstrate exactly-once behavior across external systems.
Keep this next verification small and discriminating. Select the receiver's actual persistence seam and exercise an interruption on each side of its declared commit point. Then inspect the durable state and admitted event record after restart. Those observations answer a different question from the provider-switch tests below, so report them separately rather than adding unexecuted durability claims to the passing result.
Run the original contract before accepting the switch
Executed locally on November 4, 2026 with Python 3.14.7: the eight-test contract produced two failures against BuggyRouter and passed all eight tests against Router. The failing assertions expose rejection of the original A callback and acceptance of a B-labeled callback against A's case. These are synthetic local results; no deployed KYC provider was tested.
MODEL_VARIANT=BuggyRouter python3 -B -m unittest -v test_routing
# Ran 8 tests; FAILED (failures=2); exit 1
MODEL_VARIANT=Router python3 -B -m unittest -v test_routing
# Ran 8 tests; OK; exit 0
The callback contract is summarized here. TERMINAL_UNCHANGED is implemented for a distinct event repeating the same terminal outcome, but that branch is outside the eight asserted acceptance cases.
| Callback observation | Expected disposition | Application effect |
|---|---|---|
| Original A case after the route selects B | APPLIED | Original application resolves |
| Identical repeated provider event | DUPLICATE | No additional transition |
| Provider or case does not match the assignment | QUARANTINED | No transition |
| Application does not exist | QUARANTINED | No transition |
| Seen event ID with changed semantic content | EVENT_CONFLICT | First meaning retained |
| Distinct event contradicts a terminal result | TERMINAL_CONFLICT | Existing result retained |
Save the complete runtime and test source below in one directory. The implementation selector changes which receiver is exercised while preserving the test assertions. Run the deliberate broken implementation first to confirm that the suite detects the route-only ownership mistake. Then run the corrected implementation against the same contract. Keep the failing output beside the passing output.
routing.py
"""Sequential in-memory teaching model; no authentication or signature checks."""
from dataclasses import dataclass
@dataclass(frozen=True)
class Assignment:
application_id: str
provider: str
provider_case_id: str
assigned_route_revision: int
@dataclass(frozen=True)
class Callback:
provider: str
provider_case_id: str
application_id: str
event_id: str
outcome: str
@dataclass(frozen=True)
class CallbackRecord:
callback: Callback
disposition: str
class BuggyRouter:
def __init__(self, provider):
self.provider = provider
self.revision = 1
self.route_journal = [(self.revision, provider)]
self.assignments = {}
self.states = {}
self.events = {}
self.callback_journal = []
self.effects = []
def switch(self, provider):
self.revision += 1
self.provider = provider
self.route_journal.append((self.revision, provider))
def create(self, application_id, provider_case_id):
if application_id in self.assignments:
raise ValueError('application already assigned')
assignment = Assignment(application_id, self.provider, provider_case_id,
self.revision)
self.assignments[application_id] = assignment
self.states[application_id] = 'PENDING'
return assignment
def matches(self, assignment, candidate):
# Deliberate defect: route for new work is used as historical owner.
return (candidate.provider == self.provider and
candidate.provider_case_id == assignment.provider_case_id and
candidate.application_id == assignment.application_id)
def callback(self, candidate):
if candidate.outcome not in ('VERIFIED', 'REJECTED'):
raise ValueError('fixture accepts only terminal outcomes')
key = (candidate.provider, candidate.event_id)
previous = self.events.get(key)
if previous is not None:
disposition = 'DUPLICATE' if previous == candidate else 'EVENT_CONFLICT'
else:
self.events[key] = candidate
assignment = self.assignments.get(candidate.application_id)
if assignment is None or not self.matches(assignment, candidate):
disposition = 'QUARANTINED'
elif self.states[candidate.application_id] != 'PENDING':
disposition = ('TERMINAL_CONFLICT'
if self.states[candidate.application_id] != candidate.outcome
else 'TERMINAL_UNCHANGED')
else:
self.states[candidate.application_id] = candidate.outcome
self.effects.append((candidate.application_id, candidate.outcome))
disposition = 'APPLIED'
self.callback_journal.append(CallbackRecord(candidate, disposition))
return disposition
class Router(BuggyRouter):
def matches(self, assignment, candidate):
return (candidate.provider == assignment.provider and
candidate.provider_case_id == assignment.provider_case_id and
candidate.application_id == assignment.application_id)
test_routing.py
"""Observable contracts for the synthetic assignment/callback model."""
import os
import unittest
from dataclasses import replace
from routing import Callback
class RoutingContract(unittest.TestCase):
def setUp(self):
import routing
self.model = getattr(routing, os.environ.get('MODEL_VARIANT', 'Router'))('A')
self.original = self.model.create('app-old', 'case-A-17')
self.event = Callback('A', 'case-A-17', 'app-old', 'event-1', 'VERIFIED')
def test_old_pending_callback_after_switch_keeps_original_owner(self):
self.model.switch('B')
self.assertEqual(self.model.callback(self.event), 'APPLIED')
self.assertEqual(self.model.assignments['app-old'], self.original)
self.assertEqual(self.model.states['app-old'], 'VERIFIED')
self.assertEqual(self.model.effects, [('app-old', 'VERIFIED')])
self.assertEqual(self.model.callback_journal[-1].disposition, 'APPLIED')
def test_new_application_uses_new_route(self):
self.model.callback(self.event)
self.model.switch('B')
new = self.model.create('app-new', 'case-B-28')
self.assertEqual((new.application_id, new.provider, new.provider_case_id,
new.assigned_route_revision), ('app-new', 'B', 'case-B-28', 2))
self.assertEqual(self.model.assignments['app-old'], self.original)
self.assertEqual(self.original.assigned_route_revision, 1)
self.assertEqual(self.model.states, {'app-old': 'VERIFIED', 'app-new': 'PENDING'})
new_event = Callback('B', 'case-B-28', 'app-new', 'event-1', 'VERIFIED')
self.assertEqual(self.model.callback(new_event), 'APPLIED')
self.assertEqual(self.model.effects, [('app-old', 'VERIFIED'),
('app-new', 'VERIFIED')])
def test_identical_event_is_duplicate_with_one_effect(self):
self.assertEqual(self.model.callback(self.event), 'APPLIED')
self.assertEqual(self.model.callback(self.event), 'DUPLICATE')
self.assertEqual(self.model.states['app-old'], 'VERIFIED')
self.assertEqual(self.model.effects, [('app-old', 'VERIFIED')])
self.assertEqual([entry.disposition for entry in self.model.callback_journal],
['APPLIED', 'DUPLICATE'])
def test_wrong_provider_case_pair_is_quarantined(self):
self.model.switch('B')
wrong = replace(self.event, provider='B')
self.assertEqual(self.model.callback(wrong), 'QUARANTINED')
self.assertEqual(self.model.states['app-old'], 'PENDING')
self.assertEqual(self.model.effects, [])
self.assertEqual(self.model.callback_journal[-1].callback, wrong)
self.assertEqual(self.model.callback_journal[-1].disposition, 'QUARANTINED')
def test_unknown_application_is_quarantined(self):
unknown = replace(self.event, application_id='app-unknown')
self.assertEqual(self.model.callback(unknown), 'QUARANTINED')
self.assertEqual(self.model.states, {'app-old': 'PENDING'})
self.assertEqual(self.model.effects, [])
self.assertEqual(self.model.callback_journal[-1].disposition, 'QUARANTINED')
def test_reused_event_id_with_changed_outcome_is_conflict(self):
self.model.callback(self.event)
changed = replace(self.event, outcome='REJECTED')
self.assertEqual(self.model.callback(changed), 'EVENT_CONFLICT')
self.assertEqual(self.model.states['app-old'], 'VERIFIED')
self.assertEqual(self.model.effects, [('app-old', 'VERIFIED')])
self.assertEqual([entry.disposition for entry in self.model.callback_journal],
['APPLIED', 'EVENT_CONFLICT'])
def test_second_event_cannot_contradict_terminal_decision(self):
self.model.callback(self.event)
contrary = replace(self.event, event_id='event-2', outcome='REJECTED')
self.assertEqual(self.model.callback(contrary), 'TERMINAL_CONFLICT')
self.assertEqual(self.model.states['app-old'], 'VERIFIED')
self.assertEqual(self.model.effects, [('app-old', 'VERIFIED')])
self.assertEqual(self.model.callback_journal[-1].disposition, 'TERMINAL_CONFLICT')
def test_route_journal_is_distinct_from_application_decision(self):
self.model.callback(self.event)
self.model.switch('B')
self.assertEqual(self.model.route_journal, [(1, 'A'), (2, 'B')])
self.assertEqual(self.model.states['app-old'], 'VERIFIED')
self.assertEqual(self.model.effects, [('app-old', 'VERIFIED')])
self.assertEqual([entry.disposition for entry in self.model.callback_journal],
['APPLIED'])
self.assertEqual(self.model.assignments['app-old'], self.original)
if __name__ == '__main__':
unittest.main(verbosity=2)
The Python unittest documentation describes running a named test module and comparing observable results. The dataclasses documentation explains the immutable records used in the fixture. Those language facilities help express a small model; they do not provide provider authentication, database isolation or regulatory validation.
Read the test names as acceptance conditions, then inspect the actual assertions. A test called duplicate handling is useful only if it checks that repeated delivery preserves the decision and does not add another transition. A test called cutover is insufficient if it creates all its applications after the switch. Preserve a genuinely pending old-provider application as the starting condition.
Run the cases against the boundary that owns the behavior. These direct-call tests isolate assignment and transition decisions without network timing. An adapter suite must additionally verify provider-field mapping and transport outcomes. A browser demonstration cannot replace those checks, and a passing in-memory suite cannot establish that a deployed receiver uses the same implementation or configuration.
Define the release evidence and its limits
Before scheduling a real cutover, agree who owns the remaining A applications and how their unresolved events will be inspected. The release record should include the route change, a sample old assignment, a new B assignment and the expected handling of late callbacks. The fixture provides a repeatable contract for that conversation, not an approval to change production routing.
A missing callback remains missing evidence. Switching to B does not establish that A rejected an application or that its pending case can be deleted. Obtain the provider's actual case state through the authorized operational process. If the old source is unavailable, retain the unresolved application and its owner rather than resolving it by copying a result from a newly created case.
Likewise, a successful new-provider flow demonstrates only that the new path works under its tested conditions. It does not show that the old queue has drained. Report both populations and their evidence separately. An apparently healthy admission dashboard can coexist with old cases that no longer receive usable callbacks because a route-only ownership check rejects them.
Use a rollback rehearsal to examine new admissions as well. Restoring A as the default should not transfer B's already assigned applications back to A. The assignment principle applies across each configuration change. This specific suite exercises a forward switch; a deployed rollback path still needs a case with an existing B assignment and its own observable result.
This example makes no claim about KYC policy completeness, sanctions screening, legal retention or approval thresholds. Its terminal states are software model labels. Provider-specific decision semantics and the organization's permitted workflow must be defined separately. A passing callback ownership test cannot establish that an applicant is eligible for a service or that the verification process meets a jurisdiction's requirements.
The next useful integration check is precise: use the real adapter's documented case identifiers, create one pending assignment before cutover and deliver the old provider's admitted callback afterward. Verify the original assignment, resulting state and attributable transition record together. Keep the old application's owner visible even after the new route is active.
More insights to read
- How to Preserve KYC Decision History Across Provider Changes
- When a KYC Provider Change Becomes a Product Migration
- Cutover Acceptance Records: Baseline, Trigger and Return State
- Retry Identity Fieldbook: Transport Attempts and Business Operations
- Batch Recovery Acceptance: Completed Rows and Remaining Work
About the author
Dmytro Nasyrov. Photo supplied by the author.
Written by Dmytro Nasyrov PhD, software architect with 24 years of production experience. Dmytro is the founder and CTO of Pharos Production. He works on production software architecture for FinTech, AI, Web3 and blockchain systems.


Top comments (0)