# Cancel an identity check before it unlocks an account

Source: https://rasa.community/library/casebook/step-up-authentication/
Author: Rod Rivera
Published: 2026-10-19T09:00:00Z

A customer cancels an identity check after approval has already been recorded. The assistant says it is cancelled, but can that earlier approval still unlock the account?

You will check the stored state in a fictional verification service. The exercise deliberately removes the cancellation write to show why checking the reply alone can miss a live approval.

Presentation of the recorded offline result from the deliberate mutation on 8 October 2026:

```json
{
  "case": "step-up-authentication",
  "label": "deliberate-cancellation-write-removal",
  "output": {
    "account_locked": false,
    "cancel_result": "cancelled",
    "change_count": 1,
    "dispatch_reason": "verified_fixture_receipt",
    "dispatch_status": "succeeded",
    "effects": 1
  }
}
```

The shipped code refuses the same sequence and leaves the account locked. The difference is the authorisation record, not the cancellation wording.

## Approve first, then try cancelling

Orchard Works is a fictional company. Its fixture lets an employee request an account unlock and approve it through a simulated registered device. No phone or identity provider receives that approval prompt. The exercise policy is synthetic.

The [existing cancellation test](https://github.com/RasaHQ/rasa-community-resources/blob/4aa0c4419dc193fef7a969c12d59edcf720f2606/examples/mantle-voice-step-up-authentication-claude/tests/test_guard.py) cancels a challenge after it is sent. It checks that a later verification query still reports cancelled and the account remains unchanged. Another test approves a challenge, makes its matching change once and refuses replay.

Those tests leave a useful question between them: what if approval is already recorded when the caller cancels, but dispatch has not run? That is the sequence this tutorial exercises.

| Identical sequence                   | Shipped cancellation                          | Deliberately removed state write           |
| ------------------------------------ | --------------------------------------------- | ------------------------------------------ |
| Prepare account unlock               | Request prepared                              | Request prepared                           |
| Start verification and read approval | Challenge approved                            | Challenge approved                         |
| Cancel verification                  | Reply says cancelled; state becomes cancelled | Reply says cancelled; state stays approved |
| Attempt the prepared change          | Blocked, no effects                           | Succeeded, one effect                      |
| Inspect account and change ledger    | Account locked, no changes                    | Account unlocked, one change               |

The table compares an unmodified fixture with an intentionally broken teaching variant. It does not report a discovered failure in the shipped cancellation function.

Get the [public companion project](https://github.com/RasaHQ/rasa-community-resources/tree/4aa0c4419dc193fef7a969c12d59edcf720f2606/examples/mantle-voice-step-up-authentication-claude) at the revision used here:

```sh
git clone https://github.com/RasaHQ/rasa-community-resources.git cancellation-lab
cd cancellation-lab
git checkout 4aa0c4419dc193fef7a969c12d59edcf720f2606
cd examples/mantle-voice-step-up-authentication-claude
```

The fixture tests run without a licence or provider keys:

::run{cmd="python3 -m unittest discover -s tests -v"}

For the selected test receipt, run from its `tests` directory:

```sh
cd tests
python3 -B -m unittest -v \
  test_guard.GuardTests.test_cancel_stops_the_challenge_and_keeps_access \
  test_guard.GuardTests.test_approved_challenge_changes_exactly_one_thing_once
cd ..
```

Actual selected-test output:

```text
test_cancel_stops_the_challenge_and_keeps_access (test_guard.GuardTests.test_cancel_stops_the_challenge_and_keeps_access) ... ok
test_approved_challenge_changes_exactly_one_thing_once (test_guard.GuardTests.test_approved_challenge_changes_exactly_one_thing_once) ... ok

----------------------------------------------------------------------
Ran 2 tests in 0.000s

OK
```

These tests establish Python fixture behaviour. They do not test a model handling a spoken cancellation.

## Inspect the state dispatch will read

The [cancellation function](https://github.com/RasaHQ/rasa-community-resources/blob/4aa0c4419dc193fef7a969c12d59edcf720f2606/examples/mantle-voice-step-up-authentication-claude/lib/access.py#L359-L375) changes the challenge record before returning the reply:

```python
if challenge["state"] in ("sent", "waiting", "approved"):
    challenge["state"] = "cancelled"
```

Including approved in that condition matters. The dispatch guard accepts an approved challenge; leaving it approved would preserve the permission the caller withdrew.

A later [verification check](https://github.com/RasaHQ/rasa-community-resources/blob/4aa0c4419dc193fef7a969c12d59edcf720f2606/examples/mantle-voice-step-up-authentication-claude/lib/access.py#L269-L278) returns closed states without asking the simulated device again:

```python
if challenge["state"] in ("consumed", "cancelled", "invalidated", "denied", "timed_out"):
    return _closed(challenge, ref)
```

Cancelled must remain a closed state. Querying status should not revive it.

:::diagram{title="Cancellation removes approval before the access change"}

```dot
rankdir=LR;
approved [label="Approved challenge"];
cancel [label="Write cancelled state"];
dispatch [label="Dispatch reads challenge state"];
blocked [label="Cancelled: no access change", class="blocked"];
consume [label="Approved: consume challenge"];
change [label="Apply matching access change", class="ok"];
approved -> cancel [label="caller cancels"];
cancel -> dispatch;
dispatch -> blocked [label="state is cancelled"];
approved -> dispatch [label="no cancellation"];
dispatch -> consume [label="bindings pass"];
consume -> change;
```

:::

The diagram shows a sequential boundary. Dispatch reads the current state. It consumes an accepted challenge before applying the change. This fixture has no atomic concurrent cancellation protocol.

## Attempt dispatch after cancellation

The [dispatch function](https://github.com/RasaHQ/rasa-community-resources/blob/4aa0c4419dc193fef7a969c12d59edcf720f2606/examples/mantle-voice-step-up-authentication-claude/lib/access.py#L305-L336) checks the employee, action and challenge state. Its state check is:

```text
"challenge_consumed": challenge is not None and challenge["state"] == "approved",
```

The predicate name describes the guard contract; the comparison requires approval before dispatch. If the checks pass, the function then writes consumed before calling the service's apply method. The existing replay test covers that single challenge becoming unusable after its change.

Cancellation must update this same record. A separate conversational flag that says the caller cancelled would not satisfy the guard's state check.

In [skills/access_reset/tools.py](https://github.com/RasaHQ/rasa-community-resources/blob/4aa0c4419dc193fef7a969c12d59edcf720f2606/examples/mantle-voice-step-up-authentication-claude/skills/access_reset/tools.py), the Rasa cancellation adapter passes the reference to the service:

```python
async def cancel_verification(challenge_ref: str, context: ToolContext = None) -> ToolResult:
    """Cancel a verification challenge.

    Args:
        challenge_ref: The challenge_ref to cancel.
    """
    return ToolResult(llm_response=ow.cancel_verification(_service(), challenge_ref))
```

The change adapter calls the dispatch function with the challenge, employee and action. The [skill instructions](https://github.com/RasaHQ/rasa-community-resources/blob/4aa0c4419dc193fef7a969c12d59edcf720f2606/examples/mantle-voice-step-up-authentication-claude/skills/access_reset/skill.md) tell the agent to call cancellation after a prompt has been sent. The service record is what makes that cancellation effective.

## Remove only the cancellation write

Paste this exercise into the project directory. It loads the shipped library, then compiles a separate variant in memory. It edits no source file.

```sh
python3 - <<'PY'
from pathlib import Path
import types
path = Path("lib/access.py").resolve()
source = path.read_text()
anchor = 'challenge["state"] = "cancelled"'
assert source.count(anchor) == 1
for broken in (False, True):
    code = source.replace(anchor, 'challenge["state"] = challenge["state"]') if broken else source
    module = types.ModuleType("cancel_mutation" if broken else "shipped_access")
    module.__file__ = str(path)
    exec(compile(code, str(path), "exec"), module.__dict__)
    service = module.IdentityService("cancel-proof")
    prepared = module.prepare_access_request(service, "Tara Brennan", "unlock_account")
    sent = module.start_verification(service, prepared["request_ref"], prepared["request_ref"])
    ref = sent["challenge_ref"]
    assert module.check_verification(service, ref)["status"] == "approved"
    cancelled = module.cancel_verification(service, ref)
    late = module.change_access(service, ref, prepared["employee_ref"], "unlock_account")
    locked = service.access(prepared["employee_ref"])["account_locked"]
    print("deliberately_broken" if broken else "shipped",
          "cancel", cancelled["status"], "dispatch", late["status"],
          late["reason"], "effects", late["effects"],
          "account_locked", locked, "change_count", len(service.changes))
    assert late["status"] == ("succeeded" if broken else "blocked")
    assert locked == (not broken)
    assert len(service.changes) == int(broken)
PY
```

Actual output:

```text
shipped cancel cancelled dispatch blocked challenge_not_verified effects 0 account_locked True change_count 0
deliberately_broken cancel cancelled dispatch succeeded verified_fixture_receipt effects 1 account_locked False change_count 1
```

Both cancellation replies say cancelled. Only the shipped branch changes the record that dispatch checks. The deliberately broken branch explains why checking the reply alone would be a weak acceptance test.

## Assert the account and ledger too

Inspect the account's lock and the count of recorded changes after dispatch. A blocked result accompanied by an unlock would still be a failure. The exercise therefore asserts the service state, not only its return code.

:::checkpoint{id="stepup-cancelled-reply-state" question="The cancel tool returns cancelled. Is that enough to prove the prepared change cannot run?" options="Yes because the reply is authoritative,No inspect dispatch and account state,Only if the model repeats the cancellation" answer="1"}
The deliberately broken branch returns that same reply while leaving approval usable. Try dispatch, then assert the account and change ledger.
:::

The price of closing an approved challenge is that a caller who changes their mind again needs a new verification attempt. Keeping approval alive for convenience would let delayed work use withdrawn authority.

:::solution{title="Show what cancellation after dispatch means"}
If the challenge is already consumed, the shipped cancellation function returns `already_used` and `access_unchanged: False`. Its next step says cancellation does not undo the change and routes reversal requests to the identity desk.

That is a different situation from the exercise, which cancels before dispatch. A truthful cancellation result must distinguish stopping a pending change from requesting reversal of a completed one.
:::

:::callout{type="warn" title="This is a sequential cancellation proof"}
The fixture uses mutable state inside one process. It proves neither an atomic race between cancellation and dispatch nor durable recovery after a crash. It does not test clock expiry, uniqueness across several challenges or a real identity provider. A production service needs evidence for those properties separately.
:::

On 8 October 2026, the full suite also ran with the actual Rasa Pro `3.21.0.dev5` SDK installed, without skips. Project validation accepted the skills and tools. The commands from the selected project are:

```sh
uv run --locked python -m unittest discover -s tests -v
make validate
```

Actual output excerpts:

```text
Ran 17 tests in 0.001s

OK
```

```text
validate_project: ok
```

These are offline SDK checks. No licensed training, model or speech-provider call ran for these receipts.

For an optional microphone check, use Python 3.12 and only this project. Its [setup files](https://github.com/RasaHQ/rasa-community-resources/tree/4aa0c4419dc193fef7a969c12d59edcf720f2606/examples/mantle-voice-step-up-authentication-claude) provide the lock and local environment template:

```sh
uv sync --locked --python 3.12
make env
# Fill .env locally with RASA_LICENSE, ANTHROPIC_API_KEY
# and DEEPGRAM_API_KEY. Do not commit it.
make validate
make train
make inspect
```

The pinned [integrations.yml](https://github.com/RasaHQ/rasa-community-resources/blob/4aa0c4419dc193fef7a969c12d59edcf720f2606/examples/mantle-voice-step-up-authentication-claude/integrations.yml) selects Claude and Deepgram transcription and speech. Provider calls are billed. The device response remains simulated even when you speak to the agent.

Ask to unlock Tara Brennan's account, then cancel after the approval prompt is sent. Answer two acceptance questions from the Inspector: did the agent call cancellation on the challenge it started? After attempting the change with that cancelled reference, did the account stay locked with no recorded change? The second question requires inspecting the service, not just listening to the reply.

::cta{href="/library/tutorials/guarding-irreversible-actions/" label="Build the execution guard before the side effect"}