Conversation designer / UX specialist · Banking & fintech (external)
Recover a card dispute after its reply is lost
Look up the original dispute before filing again when the service loses its acknowledgement.
Key takeaways (3)
- A missing acknowledgement can leave a recorded case that the filing response cannot confirm.
- Keep the original submission key and use a status lookup to resolve an unconfirmed filing.
- A recorded dispute starts review and does not establish a refund or credit decision.
A customer asks a banking assistant to dispute a card transaction. The service creates the dispute, but its reply is lost. If the assistant files again without checking, it may treat an existing dispute as a new request.
This fictional example shows how to look up the original attempt and recover its reference. You will check the recorded dispute rather than infer failure from a missing reply.
These are selected fields from the recorded offline fixture results, shown as a table. The complete unedited trace is below.
| Attempt | Submission key | Status | New cases |
|---|---|---|---|
| File the dispute; acknowledgement lost | NB-SUB-5D9AD35A | Pending | 1 |
| Look up that same submission key | NB-SUB-5D9AD35A | Recorded | 0 |
| Repeat the identical filing call | NB-SUB-5D9AD35A | Pending replay | 0 |
Recover the existing attempt before considering another write. The last row reuses the existing case and tries the filing read-back again. The dedicated status lookup reads it through a different path.
Separate a lost receipt from an absent case
git clone https://github.com/RasaHQ/rasa-community-resources.git
cd rasa-community-resources
git checkout 4aa0c4419dc193fef7a969c12d59edcf720f2606
cd examples/mantle-voice-banking-dispute-claude
Keep the full clone so the neighbouring case contract is available. Run all commands below from this selected example directory. The inline exercise uses only Python’s standard library and calls no provider or Rasa engine.
- Any system
python3 -B -m unittest discover -s tests -v
The companion tests include
test_lost_acknowledgement_is_pending_then_found_by_submission_key
and test_unavailable_case_service_is_pending_and_routed. They teach two
recovery states: lookup can find the case, or lookup cannot establish its state.
Do not map every non-success response to a “File again” choice. First inspect whether the operation has a submission identity and a supported read route. That identity lets your recovery UI refer to the attempt already made.
The exercise uses a transaction whose case-service mode is ack_lost.
The filing function records the case, but its filing read-back fails. Its
status lookup can read the same case. The table shows the resulting distinction:
| Operation in the same fixture call | Status | New effects | Cases stored | What the caller can be told |
|---|---|---|---|---|
| First filing | Pending | 1 | 1 | Filing is not confirmed yet |
| Identical filing retry | Pending; replay | 0 | 1 | Filing is still not confirmed by this response |
| Lookup with original key | Recorded | No write | 1 | Give the dispute reference and review step |
That is a read/write distinction, not a claim that any pending real-world submission exists. The status service must establish which state applies.
Retain the original submission key
The pinned submission_key function derives the key from conversation identity and the selected transaction reference:
def submission_key(conversation_id: str, txn_ref: str) -> str:
"""One key per call and transaction, so a retried filing is a replay, not a second case."""
return f"NB-SUB-{_digest(conversation_id, txn_ref)}"
The Rasa filing wrapper reads the verified customer and selected transaction from memory. It passes the caller’s statement and conversation identity to the service:
result = nd.file_dispute(
_service(),
_get(context, CUSTOMER_KEY),
_get(context, "selected_transaction_ref"),
transaction_ref,
customer_statement,
conversation_id=_conversation_id(),
)
return ToolResult(llm_response=result)
The model supplies the transaction reference copied from selection and the caller’s statement. It cannot supply the verified customer or a reimbursement decision. The returned submission key identifies the attempt for recovery. Preserve it with the pending state rather than asking the caller to recreate that identity by describing the charge again.
The status wrapper is a separate read tool:
async def check_dispute_status(submission_key: str, context: ToolContext = None) -> ToolResult:
return ToolResult(llm_response=nd.check_dispute_status(_service(), _get(context, CUSTOMER_KEY), submission_key))
The key is copied from the filing result. Do not generate a new key when checking whether the original attempt was recorded.
Compare an identical retry with the lookup
Run this complete trace from the selected example directory. It uses the original statement and call identity for both filing attempts. It asserts one stored case and zero new effects on the repeat.
python3 -B - <<'PY'
import json
from lib import disputes as nd
def emit(case, label, value):
print(json.dumps({"case": case, "label": label, "output": value}, sort_keys=True))
# A lost acknowledgement has persisted a case. Lookup is a read, not another filing.
svc = nd.LedgerService()
first = nd.file_dispute(svc, 'NB-CUST-4820', 'NB-TXN-3105', 'NB-TXN-3105', 'I did not make this purchase.', 'ack-proof')
emit('banking-dispute', 'lost-acknowledgement', first)
found = nd.check_dispute_status(svc, 'NB-CUST-4820', first['submission_key'])
emit('banking-dispute', 'lookup-same-key', dict(found, case_count=len(svc.cases)))
assert first['status'] == 'pending' and found['status'] == 'recorded' and len(svc.cases) == 1
retry = nd.file_dispute(svc, 'NB-CUST-4820', 'NB-TXN-3105', 'NB-TXN-3105', 'I did not make this purchase.', 'ack-proof')
emit('banking-dispute', 'same-call-replay', {'status': retry['status'], 'effects': retry['effects'], 'replay': retry['replay'], 'case_count': len(svc.cases)})
assert retry['effects'] == 0 and len(svc.cases) == 1
PY
Actual recorded output for these cases on 8 October 2026:
{"case": "banking-dispute", "label": "lost-acknowledgement", "output": {"card_blocked": false, "customer_statement": "I did not make this purchase.", "dispute_reference": null, "effects": 1, "facts": {"case_reference_recorded": false, "customer_statement_confirmed": true, "transaction_selected": true}, "next_review_step": null, "next_step": "The case service did not confirm the case. Say the dispute is not confirmed yet, call check_dispute_status with this submission_key, and if it is still unknown call route_disputes_desk. Do not file again and do not promise any refund or credit.", "provisional_credit": null, "reason": "case_not_recorded", "reimbursement_decision": null, "replay": false, "status": "pending", "submission_key": "NB-SUB-5D9AD35A", "transaction_label": "charge of 3,480 rupees from Saffron Table on 25 September, on your credit card ending 2 0 4 6", "transaction_ref": "NB-TXN-3105"}}
{"case": "banking-dispute", "label": "lookup-same-key", "output": {"case_count": 1, "dispute_reference": "NB-DSP-AD388151", "next_review_step": "The disputes team reviews the case and contacts the caller within 10 business days. No refund or credit has been decided.", "next_step": "Give the dispute reference and the next review step. No refund has been decided.", "reason": "found_by_submission_key", "reimbursement_decision": null, "status": "recorded", "submission_key": "NB-SUB-5D9AD35A", "transaction_ref": "NB-TXN-3105"}}
{"case": "banking-dispute", "label": "same-call-replay", "output": {"case_count": 1, "effects": 0, "replay": true, "status": "pending"}}
The experiment performs lookup before the repeated filing. The repeated write still reports pending after lookup has found the case. That makes the point sharper: a successful lookup does not repair the lost acknowledgement in the write response. Design the recovery action around the read result.
In file_dispute, replay checks whether that submission key is already in the case store. It stores a new case only when absent, then attempts the filing read-back again:
key = submission_key(conversation_id, ref)
replay = key in service.cases
if not replay:
txn = service.transactions[ref]
This receipt covers the same original call, transaction and payload only. It does not establish deduplication across calls, changed statements, process restarts or concurrent submissions. The fixture uses process-local storage; your real case service needs durable attempt identity and atomic writes.
The cost of choosing lookup-first recovery is retaining the original key and providing a read route in the product. A “Try again” button backed only by the write endpoint cannot resolve this trace, even when the write is replayed.
Keep the refund decision empty
A recorded case is a review receipt. The filing payload returns both
reimbursement_decision and provisional_credit as None. The lookup output
also carries no reimbursement decision. Nothing in this exercise executes a
refund, credit or automatic card block.
The pinned skill.md requires transaction confirmation before filing and directs pending recovery to lookup:
tool_constraints:
- file_dispute:
requires: session.open_dispute.selected_transaction_ref
requires_confirmation:
enabled: true
utter_for_confirmation: utter_confirm_dispute
utter_on_user_denial: utter_dispute_not_filed
The configuration names the engine question. It does not turn the pure-Python trace into evidence of what the caller heard. Review spoken wording separately: “recorded” describes the case, while any reimbursement awaits the service’s review decision. The fixture’s stated review interval is synthetic policy, not a promise for a real institution.
Route a state the lookup cannot establish
The lookup function checks both the submission key and
ownership. For a known owned case with unavailable lookup, it returns
unknown with case_service_unavailable. The skill then uses the disputes
desk route while keeping the caller’s filing pending.
An unrecognised key or another customer’s case also returns unknown. The desk function routes only a known owned filing; inventing a key cannot create a support record. Keep that refusal distinct from the pending owned-case path. The companion unavailable-service test verifies the latter routes successfully.

The diagram is the recovery path for a known filing. It does not imply that an unknown key establishes a case. Both a recorded receipt and a desk route leave the reimbursement decision unset.
Show how to inspect the pending recovery state
Capture the submission key from the initial response. Compare it with the key
used by lookup and any repeat. Inspect case count separately from status;
pending plus zero new effects can still describe a stored case. Give the
lookup’s reference only when it returns recorded. If a known filing remains
unknown, route it with that same key and preserve pending wording.
A separate offline check on 8 October 2026 used the pinned Rasa SDK and ran the full suite without skips. Its actual summary was:
Ran 19 tests in 1.395s
OK
Project validation also printed:
validate_project: ok
To repeat that full check after installing the selected project:
uv run --locked python -m unittest discover -s tests -v
make validate
This validates tool imports and the project shape. It does not train a model,
call a provider or establish spoken confirmation behaviour. Without Rasa
installed, the earlier command can skip the SDK-dependent tool tests; read the
summary rather than treating any OK as a full-suite result.
Try the microphone route as a separate acceptance check
Use the pinned README and install only this example with Python 3.12:
uv sync --locked --python 3.12
make env
The full clone is needed for the local patterns/voice-vendor-router dependency.
Fill .env with RASA_LICENSE, ANTHROPIC_API_KEY,
SPEECHMATICS_API_KEY and RIME_API_KEY. The project pins
Rasa Pro 3.21.0.dev5. Its integrations.yml selects
claude-sonnet-5-5, the voicerouter.providers.speechmatics.SpeechmaticsASR
input adapter and Rime mistv3 output with the ironwood voice.
The Inspector is enabled with those same engines.
make validate
make train
make inspect
Allow microphone access. This optional path uses billed APIs and was not run for these receipts. Try a proposed short caller line: “I am Priya Raghunathan, born seventeen August nineteen ninety-one. I did not make the Saffron Table charge.” After the charge is read back, confirm the statement. Inspect the pending filing’s submission key and the subsequent status lookup. Ask “Has it been recorded?” and check that the answer distinguishes recording from a refund decision. These are acceptance prompts, not recorded dialogue or a voice quality benchmark.