# Recover a card dispute after its reply is lost

Source: https://rasa.community/library/casebook/banking-dispute/
Author: Rod Rivera
Published: 2026-10-16T09:00:00Z

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.

:::callout{type="info" title="A fictional service exercise"}
Northgate Bank, its customers and the service policy are invented. This exercise checks offline Python recovery logic. It does not measure a model, speech provider or real dispute service. Separate SDK checks below validate imports and project structure.
:::

## Separate a lost receipt from an absent case

```bash
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.

::run{cmd="python3 -B -m unittest discover -s tests -v"}
The [companion tests](https://github.com/RasaHQ/rasa-community-resources/blob/4aa0c4419dc193fef7a969c12d59edcf720f2606/examples/mantle-voice-banking-dispute-claude/tests/test_guard.py) 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](https://github.com/RasaHQ/rasa-community-resources/blob/4aa0c4419dc193fef7a969c12d59edcf720f2606/examples/mantle-voice-banking-dispute-claude/lib/disputes.py#L206-L208) derives the key from
conversation identity and the selected transaction reference:

```python
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](https://github.com/RasaHQ/rasa-community-resources/blob/4aa0c4419dc193fef7a969c12d59edcf720f2606/examples/mantle-voice-banking-dispute-claude/skills/open_dispute/tools.py) reads the verified customer
and selected transaction from memory. It passes the caller's statement and
conversation identity to the service:

```python
    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:

```python
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.

```bash
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:

```text
{"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](https://github.com/RasaHQ/rasa-community-resources/blob/4aa0c4419dc193fef7a969c12d59edcf720f2606/examples/mantle-voice-banking-dispute-claude/lib/disputes.py#L310-L375), 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:

```python
    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.

:::checkpoint{id="dispute-lost-ack-lookup" question="A dispute filing is pending and its acknowledgement is missing. What should recovery do first?" options="File the same charge again,Check status using the original submission key,Promise a refund while waiting" answer="1"}
The write may already have happened. Read its state under the original key;
if it remains unknown, keep it pending and use the supported desk route.
:::

## 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](https://github.com/RasaHQ/rasa-community-resources/blob/4aa0c4419dc193fef7a969c12d59edcf720f2606/examples/mantle-voice-banking-dispute-claude/skills/open_dispute/skill.md) requires transaction confirmation
before filing and directs pending recovery to lookup:

```yaml
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](https://github.com/RasaHQ/rasa-community-resources/blob/4aa0c4419dc193fef7a969c12d59edcf720f2606/examples/mantle-voice-banking-dispute-claude/lib/disputes.py#L378-L407) 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.

:::diagram{title="Recover the receipt with a read before another write"}

```dot
rankdir=LR;
file [label="File selected transaction"];
pending [label="Acknowledgement missing: pending"];
key [label="Retain original submission key"];
lookup [label="Read status for owned filing"];
recorded [label="Recorded: reference + review step", class="ok"];
unknown [label="Known filing; state unknown", class="blocked"];
desk [label="Disputes desk; keep pending"];
file -> pending;
pending -> key;
key -> lookup;
lookup -> recorded [label="found"];
lookup -> unknown [label="service unavailable"];
unknown -> desk;
```

:::

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.

:::solution{title="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:

```text
Ran 19 tests in 1.395s

OK
```

Project validation also printed:

```text
validate_project: ok
```

To repeat that full check after installing the selected project:

```bash
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](https://github.com/RasaHQ/rasa-community-resources/blob/4aa0c4419dc193fef7a969c12d59edcf720f2606/examples/mantle-voice-banking-dispute-claude/README.md) and install only this example with Python 3.12:

```bash
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](https://github.com/RasaHQ/rasa-community-resources/blob/4aa0c4419dc193fef7a969c12d59edcf720f2606/examples/mantle-voice-banking-dispute-claude/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.

```bash
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.

:::cta{href="/library/tutorials/guarding-irreversible-actions/" label="Test the execution guard and recovery path for your write"}
Make your real service expose a read by the original attempt identity before
adding a retry control to the conversation.
:::