Define identity and responsibility
A mandate migration is not established by equal customer counts. The exercise uses two creditors with UMR-1 to demonstrate a collision: storing only UMR loses one relationship. Preserving creditor and reference distinguishes authorizations. Public EPC guidance assigns the creditor responsibility for retaining the original mandate and amendment or cancellation information. In the fictional project, identify who supplies these records, who transforms them, and who confirms they remain recoverable. The lab contains no signatures and checks no legal validity; it addresses data consistency and evidence the PM should request.
Preserve the state applicable in time
Version 1 is effective at 10, version 2 changes the account at 20, and version 3 records revocation at 30. These are synthetic units without a banking calendar. At 19, the model selects version 1; exactly at 20, it selects version 2. A new decision at 30 encounters revocation while state at 15 remains reconstructable. Always selecting the last row would destroy this distinction. When two versions share an effective time, the exercise refuses to choose by file order. Actual precedence rules need agreement and evidence from data owners.
Bind confirmation to the data used
The B2B flow requires attention to mandate-data confirmation and verification by the debtor PSP. To teach that dependency, DR defines confirmedVersion: confirmation must point to the evaluated version. Account B may be correct while confirmation remains linked only to the account A version. The model holds the decision. This field and version policy are teaching choices, not official message fields or a universal EPC procedure. Manually changing the number creates no evidence. Actual integration must define confirmed data, relevant amendments, and applicable acceptance criteria.
Investigate cause in context
In a migration rehearsal, MD02 accompanies transmitted data differing from the stored mandate. Starting with amendment propagation and mapping is a more useful hypothesis than increasing infrastructure resources. MD01 alone does not establish that every debtor revoked a mandate; examine confirmation and each case’s context. AC13 in a B2B flow targeting a consumer account directs review toward instrument and account. Do not silently convert scheme merely to make the file pass. These decisions use the consulted v8.1 reason-code guidance; they replace neither complete rules, operational agreements, nor legal analysis.
Demonstrate use after cutover
Restoration can load correct revocation into the database while a worker retains an active version in memory. Its health check stays green. Request evidence of loaded state and the decision produced by every affected consumer. The earlier lesson used snapshots to bind VOP responses; here the problem is preserving and applying mandate state through migration. These checks do not replace each other. Include a pre-amendment case, a boundary case, and a post-revocation case in the cutover plan. If a consumer remains outdated, retain the action and impact in operational handover reporting.
Run the model and retain limits
Reserve fifteen minutes to predict snapshots and decisions, twenty to run and introduce collisions, fifteen to compare histories, and ten to explain outcomes. Copy the complete program and run python3 run.py --output evidence.json. The standard library is sufficient. Its report includes forty checks, also covering reconciliation in the next lesson. An independent copy prevents changes to a returned snapshot from modifying original history. This demonstrates neither persistence, concurrency, nor power-failure resilience. Data is fictional, with no real accounts, PSP calls, or collection execution. Retain predictions, results, program hash, and questions the exercise leaves unresolved.
"""Original DR collection evidence model. Python 3.13; standard library only.
python3 run.py --output evidence.json
Controlled fictional snapshots and observations. No bank connection, SEPA
message parser, mandate validity decision, payment execution or legal advice.
Integer time and cents, immutable snapshots and version-bound confirmation are
local teaching rules. Only one full debit after a settlement is modeled.
"""
import argparse
from copy import deepcopy
import hashlib
import json
from pathlib import Path
import sys
checks=[]
def check(name,actual,expected):
assert actual==expected,(name,actual,expected)
checks.append(dict(name=name,actual=actual,expected=expected,passed=True))
def reject(fn):
try:fn
except ValueError as e:return str(e)
raise AssertionError('Expected rejection')
def snapshot(history,creditor,umr,at):
candidates=[s for s in history.get((creditor,umr),[]) if s['effective']<=at]
if not candidates:raise ValueError('no applicable snapshot')
times=[s['effective'] for s in candidates]
if len(times)!=len(set(times)):raise ValueError('ambiguous effective time')
return deepcopy(max(candidates,key=lambda s:s['effective']))
def eligibility(history,collection):
try:s=snapshot(history,collection['creditor'],collection['umr'],collection['at'])
except ValueError as e:return str(e)
if s['state']!='ACTIVE':return 'inactive mandate'
if s['scheme']!=collection['scheme']:return 'scheme mismatch'
if s['account']!=collection['account']:return 'account mismatch'
if s['scheme']=='B2B' and s['confirmedVersion']!=s['version']:return 'confirmation not bound to version'
return 'LOCAL_CHECKS_PASS'
def reconcile(collections,events):
# Isolated candidate. Any error discards it; inputs remain untouched.
rows={k:dict(amount=v['amount'],currency=v['currency'],state='UNKNOWN',credit=0,debit=0) for k,v in collections.items}
seen={}
for e in events:
identity=e['event'];signature=tuple(e[k]for k in('collection','kind','amount','currency'))
if identity in seen:
if seen[identity]!=signature:raise ValueError('event identity conflict')
continue
if e['collection'] not in rows:raise ValueError('unknown collection')
row=rows[e['collection']]
if e['amount']!=row['amount'] or type(e['amount']) is not int:raise ValueError('amount mismatch')
if e['currency']!=row['currency']:raise ValueError('currency mismatch')
kind=e['kind']
if kind=='SUBMITTED':
if row['state']!='UNKNOWN':raise ValueError('unexpected submission')
row['state']='PENDING'
elif kind=='REJECTED':
if row['credit'] or row['debit']:raise ValueError('reject after movement')
row['state']='REJECTED'
elif kind=='SETTLED':
if row['credit']:raise ValueError('duplicate economic effect')
if row['state']=='REJECTED':raise ValueError('conflicting outcome')
row['credit']=row['amount'];row['state']='SETTLED'
elif kind=='REFUND_REQUEST':
if not row['credit']:raise ValueError('missing settlement evidence')
if row['debit']:raise ValueError('already reversed')
row['state']='REFUND_PENDING'
elif kind in('RETURN_DEBIT','REFUND_DEBIT'):
if not row['credit']:raise ValueError('missing settlement evidence')
if row['debit']:raise ValueError('duplicate economic effect')
row['debit']=row['amount'];row['state']='RETURNED' if kind=='RETURN_DEBIT' else 'REFUNDED'
else:raise ValueError('unknown observation kind')
seen[identity]=signature
return dict(rows=rows,observations=len(seen),credit=sum(r['credit']for r in rows.values),debit=sum(r['debit']for r in rows.values),net=sum(r['credit']-r['debit']for r in rows.values))
def main:
v1=dict(effective=10,version=1,state='ACTIVE',scheme='B2B',account='ACCOUNT-A',confirmedVersion=1)
v2={**v1,'effective':20,'version':2,'account':'ACCOUNT-B','confirmedVersion':1}
v3={**v2,'effective':30,'version':3,'state':'REVOKED'}
history={('CREDITOR-A','UMR-1'):[v1,v2,v3],('CREDITOR-B','UMR-1'):[{**v1,'account':'ACCOUNT-Z'}]}
before=deepcopy(history)
c=dict(creditor='CREDITOR-A',umr='UMR-1',at=15,scheme='B2B',account='ACCOUNT-A')
check('snapshot before amendment',snapshot(history,'CREDITOR-A','UMR-1',19)['version'],1)
check('snapshot at amendment boundary',snapshot(history,'CREDITOR-A','UMR-1',20)['version'],2)
check('snapshot at revocation boundary',snapshot(history,'CREDITOR-A','UMR-1',30)['state'],'REVOKED')
check('creditor separates equal UMR',snapshot(history,'CREDITOR-B','UMR-1',15)['account'],'ACCOUNT-Z')
check('before first snapshot rejected',reject(lambda:snapshot(history,'CREDITOR-A','UMR-1',9)),'no applicable snapshot')
check('unknown mandate rejected',reject(lambda:snapshot(history,'MISSING','UMR-1',15)),'no applicable snapshot')
copy=snapshot(history,'CREDITOR-A','UMR-1',15);copy['account']='CHANGED'
check('snapshot copy preserves history',history==before,True)
check('original B2B snapshot passes local checks',eligibility(history,c),'LOCAL_CHECKS_PASS')
check('changed account blocks old data',eligibility(history,{**c,'at':20}),'account mismatch')
check('old confirmation does not bind new version',eligibility(history,{**c,'at':20,'account':'ACCOUNT-B'}),'confirmation not bound to version')
confirmed=deepcopy(history);confirmed[('CREDITOR-A','UMR-1')][1]['confirmedVersion']=2
check('version bound confirmation passes locally',eligibility(confirmed,{**c,'at':20,'account':'ACCOUNT-B'}),'LOCAL_CHECKS_PASS')
check('revoked mandate blocks new local check',eligibility(history,{**c,'at':30,'account':'ACCOUNT-B'}),'inactive mandate')
check('historical decision remains reconstructable',eligibility(history,c),'LOCAL_CHECKS_PASS')
check('scheme change is not implicit',eligibility(history,{**c,'scheme':'CORE'}),'scheme mismatch')
ambiguous=deepcopy(history);ambiguous[('CREDITOR-A','UMR-1')].append(dict(v1))
check('same effective time is ambiguous',reject(lambda:snapshot(ambiguous,'CREDITOR-A','UMR-1',15)),'ambiguous effective time')
collections={k:dict(amount=n,currency='EUR') for k,n in [('A',10000),('B',20000),('C',30000),('D',40000)]}
def event(i,c,kind,**change):return dict(event=i,collection=c,kind=kind,**{**collections[c],**change})
events=[event('E1','A','SETTLED'),event('E2','B','SETTLED'),event('E3','C','REJECTED'),event('E4','D','SUBMITTED'),event('E5','A','RETURN_DEBIT'),event('E6','B','REFUND_REQUEST')]
saved=deepcopy(events);out=reconcile(collections,events)
check('credit sum in integer cents',out['credit'],30000)
check('return debit sum in integer cents',out['debit'],10000)
check('net observed amount in integer cents',out['net'],20000)
check('return retains original credit',out['rows']['A']['credit'],10000)
check('refund request keeps money unchanged',out['rows']['B']['debit'],0)
check('refund request remains pending',out['rows']['B']['state'],'REFUND_PENDING')
check('submission does not prove settlement',out['rows']['D']['state'],'PENDING')
check('rejected collection has zero movement',out['rows']['C']['credit']-out['rows']['C']['debit'],0)
check('identical repeated event counted once',reconcile(collections,events+[events[0]])['observations'],6)
check('identical repeat does not change totals',reconcile(collections,events+[events[0]])['net'],20000)
check('conflicting event identity rejected',reject(lambda:reconcile(collections,events+[{**events[0],'amount':9999}])),'event identity conflict')
check('new ID cannot duplicate economic credit',reject(lambda:reconcile(collections,events+[event('E7','B','SETTLED')])),'duplicate economic effect')
check('amount mismatch rejected',reject(lambda:reconcile(collections,[event('E1','A','SETTLED',amount=10001)])),'amount mismatch')
check('currency mismatch rejected',reject(lambda:reconcile(collections,[event('E1','A','SETTLED',currency='USD')])),'currency mismatch')
check('unknown collection rejected',reject(lambda:reconcile(collections,[{**events[0],'collection':'MISSING'}])),'unknown collection')
check('return before settlement held for investigation',reject(lambda:reconcile(collections,[events[4]])),'missing settlement evidence')
check('refund completion updates net',reconcile(collections,events+[event('E7','B','REFUND_DEBIT')])['net'],0)
check('refund completion retains original credit',reconcile(collections,events+[event('E7','B','REFUND_DEBIT')])['rows']['B']['credit'],20000)
check('double debit rejected',reject(lambda:reconcile(collections,events+[event('E7','A','REFUND_DEBIT')])),'duplicate economic effect')
check('reject after settlement rejected',reject(lambda:reconcile(collections,events+[event('E7','B','REJECTED')])),'reject after movement')
check('settlement after reject needs investigation',reject(lambda:reconcile(collections,events+[event('E7','C','SETTLED')])),'conflicting outcome')
check('unknown observation kind rejected',reject(lambda:reconcile(collections,[event('E7','A','RETRY_NOW')])),'unknown observation kind')
check('failed candidates preserve event input',events==saved,True)
check('missing evidence remains unknown',reconcile(collections,[])['rows']['A']['state'],'UNKNOWN')
check('all collections retain identity',sorted(out['rows']),['A','B','C','D'])
output=dict(scope='Original mandate-snapshot and collection-observation fixtures; local eligibility is not authorization, observed net is not a bank ledger, no actual SEPA processing or conformance.',python=sys.version.split[0],runnerSha256=hashlib.sha256(Path(__file__).read_bytes).hexdigest,passed=len(checks),checks=checks)
parser=argparse.ArgumentParser;parser.add_argument('--output',required=True)
Path(parser.parse_args.output).write_text(json.dumps(output,indent=2)+'\n');print(json.dumps({'passed':len(checks),'scope':output['scope']}))
if __name__=='__main__':main
Case: migration preserves customer count but merges mandates from two creditors sharing a UMR. Reconciliation should compare relationships and history, alongside totals.
Common pitfalls
UMR alone as a key; last row as historical truth; old confirmation as evidence for new data; updated database as proof for every consumer.
Related topics: Mandates, Core, and B2B · Data and verification of payee · Changes and operational readiness
A useful migration preserves mandate meaning and enables reconstruction of a decision with data and evidence applicable at the time under review.
Reference: EPC SEPA Direct Debit overview · BigSavant Payments and SEPA professional assessment2026.10