AI product engineer · Shared primitives
Cancel an identity check before it unlocks an account
Verify that cancelling a check changes the account state as well as the assistant’s reply.
Key takeaways (3)
- Test cancellation after approval arrives, while the sensitive change is still waiting to run.
- A cancellation acknowledgement must change the authorisation state that the dispatch function reads.
- Inspect the account and change ledger as well as the reply; this sequential fixture does not prove concurrent cancellation.
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:
{
"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 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 at the revision used here:
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:
- Any system
python3 -m unittest discover -s tests -v
For the selected test receipt, run from its tests directory:
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:
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 changes the challenge record before returning the reply:
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 returns closed states without asking the simulated device again:
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.

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 checks the employee, action and challenge state. Its state check is:
"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, the Rasa cancellation adapter passes the reference to the service:
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 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.
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:
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.
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.
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.
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:
uv run --locked python -m unittest discover -s tests -v
make validate
Actual output excerpts:
Ran 17 tests in 0.001s
OK
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 provide the lock and local environment template:
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 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.