Skip to content
RasaGet a free licence
AI team casebook

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.

by Rod Rivera

About 10 minutesHands-on tutorial

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.

AttemptSubmission keyStatusNew cases
File the dispute; acknowledgement lostNB-SUB-5D9AD35APending1
Look up that same submission keyNB-SUB-5D9AD35ARecorded0
Repeat the identical filing callNB-SUB-5D9AD35APending replay0

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 callStatusNew effectsCases storedWhat the caller can be told
First filingPending11Filing is not confirmed yet
Identical filing retryPending; replay01Filing is still not confirmed by this response
Lookup with original keyRecordedNo write1Give 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.

Recover the receipt with a read before another write
FigureRecover the receipt with a read before another write

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.