# Test approval before a travel assistant releases a seat

Source: https://rasa.community/library/casebook/disruption-mode/
Author: Rod Rivera
Published: 2026-10-24T09:00:00Z

Your travel assistant helps passengers find another flight after a cancellation. Some passengers already have a seat held for them. You want to ask before giving that seat up, and make sure the answer still refers to the seat the passenger was shown.

In this tutorial, you will run a comparison against Horizon Travel, a fictional inventory service. You will test a refusal, a changed selection and a repeated answer, then inspect which holds remain. By the end, you will have concrete checks to request when choosing how your application handles approval.

The exercise sits in a separate application layer outside the model’s tools. It supplies a small confirmation controller and a deliberately broken control. It does not implement a Mantle integration. You will use invented passengers and seat inventory, with Python’s standard library.

## Start with the permission the service actually checks

A hold temporarily reserves a seat until an expiry. It gives a passenger time to accept an option; it is not a confirmed journey. Releasing it returns that seat to the fictional inventory.

The [existing release service](https://github.com/RasaHQ/rasa-community-resources/blob/0f1ce0d704d34b5adcf5afd974c056ffa9d8ebee/examples/mantle-text-disruption-mode-gpt/lib/recovery.py#L432-L448) checks that the hold belongs to the signed-in passenger and examines its current state. Its arguments name the passenger and hold. They contain no record of which confirmation question the passenger answered.

Those checks have different jobs:

| Check              | Question it answers                                                               |
| ------------------ | --------------------------------------------------------------------------------- |
| Ownership          | May this passenger change this hold?                                              |
| Current hold state | Is there an active reservation to release, or was it already released or expired? |
| Answer binding     | Which proposed release does this answer approve?                                  |

The saved Mantle example is useful context. In its [historical backup-seat conversation](https://github.com/RasaHQ/rasa-community-resources/blob/0f1ce0d704d34b5adcf5afd974c056ffa9d8ebee/examples/mantle-text-disruption-mode-gpt/case-build/results/2026-09-30-gpt-5.5-low/trackers/adversarial-second-hold-as-backup.json), the model offers to release a Miami hold before looking for an alternative. The scripted passenger answers “Yes.” and the service releases it. That answer can reasonably accept the actual offer. This is a synthetic text run from 30 September 2026 using GPT-5.5, not an observed refusal being ignored.

The same historical run also shows a correction handled by Mantle’s confirmation flow for **placing a hold**. In the [saved correction conversation](https://github.com/RasaHQ/rasa-community-resources/blob/0f1ce0d704d34b5adcf5afd974c056ffa9d8ebee/examples/mantle-text-disruption-mode-gpt/case-build/results/2026-09-30-gpt-5.5-low/trackers/correction-at-confirmation.json), the passenger changes from the nonstop flight to a Detroit connection. The model declines the pending question, selects the connection and asks again. “Yes, hold that one.” brings the engine’s confirmation question; the next “Yes.” places the new hold. That is observed behaviour for the hold tool, not a test of confirmation before release.

The following experiment asks a different, controlled question: what happens if the selected hold changes before an earlier answer is handled?

## Get the example and run its checks

You need Git, Python 3.12 and access to the public repository. Clone the public example and pin the revision below so your source and tests match:

```sh
git clone https://github.com/RasaHQ/rasa-community-resources.git travel-approval-test
cd travel-approval-test
git checkout --detach 5f6fa63c5a987c42461bf77ce6cc61576f940fef
cd examples/mantle-text-disruption-mode-gpt
```

If the checkout fails, check the repository and revision before continuing. A missing test file usually means the checkout or working directory is wrong; do not substitute an older test and copy its success summary.

Run the focused tests from that project directory:

::run{mac="python3 -m unittest discover -s tests -p test_release_review.py -v" linux="python3 -m unittest discover -s tests -p test_release_review.py -v" windows="py -3.12 -m unittest discover -s tests -p test_release_review.py -v"}

The retained selected run ended:

```text
Ran 9 tests in 0.001s

OK
```

The tests cover refusal, an unknown question, another passenger’s hold, correction, replay, accepting after a refusal, asking again about the same hold and the deliberate controls. The [qualification receipt](/receipts/travel-release-decision-qualification-20261017.json) links the unchanged log and records the executed source-file hashes. The executed Python files match the canonical revision above; the receipt retains the original executed revision, rather than reporting a new run. This selected run used a source export; the separate [full source check](/receipts/travel-release-decision-source-check-20261017.json) used the committed Git checkout.

If a test fails, retain its assertion and check your revision and Python version. The deliberately broken control has its own expected outcome; do not change a failing assertion merely to obtain the footer shown here.

## Compare an old answer with the current question

The comparison first uses the fixture’s existing Miami hold, then creates a Chicago hold through the same service. It proposes releasing Miami and changes the proposal to Chicago. Now an acceptance arrives for the **old Miami question**.

Before running the next command, predict which holds should remain. Should an answer to the old question be allowed to release Chicago?

::run{mac="python3 case-build/release_policy_comparison.py" linux="python3 case-build/release_policy_comparison.py" windows="py -3.12 case-build/release_policy_comparison.py"}

The command prints eight rows. It exits successfully when it prints the comparison, including the counterexample; the focused tests separately assert the intended state changes. The [complete retained output](/receipts/travel-release-decision-comparison-20261017.json) gives this sequence:

| Input after the change              | Unbound-answer control                           | Bound controller                                 |
| ----------------------------------- | ------------------------------------------------ | ------------------------------------------------ |
| Accept the old Miami question       | Chicago released; replay false; 1 seat returned. | Superseded; replay true; 0 seats returned.       |
| Accept the current Chicago question | Receipt replay; replay true; 0 seats returned.   | Chicago released; replay false; 1 seat returned. |
| Repeat that current acceptance      | Receipt replay; replay true; 0 seats returned.   | Receipt replay; replay true; 0 seats returned.   |

Miami stays held throughout this sequence. The difference is when Chicago is released: immediately for an old answer in the control, or only for its own question in the bound path. A `released` receipt with `replay: true` reports an earlier release; it does not return another seat. The bound path’s superseded response also replays a recorded decision, without releasing a hold.

The [comparison source](https://github.com/RasaHQ/rasa-community-resources/blob/5f6fa63c5a987c42461bf77ce6cc61576f940fef/examples/mantle-text-disruption-mode-gpt/case-build/release_policy_comparison.py) uses a fresh instance of the same service for each path. The unbound control deliberately substitutes the current question identifier for the one the answer supplies. Everything else uses the same controller and fixture service. This is a constructed mutation, not a reconstruction of the historical model’s conversation.

The output also includes a direct-service control: deliberately bypassing the application decision and calling release after a refusal releases the Miami hold. The bound path returns `declined` and keeps it active. Ownership checks alone do not enforce that application decision. The direct service returns `released` because it receives no refusal decision to enforce.

## Follow the decision before the service call

Open [`exercises/release_decision.py`](https://github.com/RasaHQ/rasa-community-resources/blob/5f6fa63c5a987c42461bf77ce6cc61576f940fef/examples/mantle-text-disruption-mode-gpt/exercises/release_decision.py). Each proposal remembers a question identifier and a hold. Creating a new proposal records the old question as `superseded` before presenting the new one.

The controller’s `decide` method handles an answer in this order:

```python
def decide(self, proposal_id: str, decision: str) -> dict:
    if proposal_id in self.completed:
        return {**deepcopy(self.completed[proposal_id]), 'replay': True}
    if not self.pending or proposal_id != self.pending['proposal_id']:
        return {'status': 'blocked', 'reason': 'no_matching_question'}
    if decision not in ('accept', 'decline'):
        return {'status': 'blocked', 'reason': 'invalid_decision'}
    hold_id = self.pending['hold_id']
    if decision == 'decline':
        result = {'status': 'declined', 'hold_id': hold_id}
    else:
        # Ownership and current state still belong to the actual service.
        result = recovery.release_hold(self.service, self.passenger_id, hold_id)
    self.completed[proposal_id] = deepcopy(result)
    self.pending = None
    return result
```

First it returns a decision already recorded for that question. That is why accepting a question after declining it does not reopen the release, and why an answer for a superseded question leaves the current hold alone.

For an unfinished decision, the identifier must match the pending question. Only a valid acceptance reaches `recovery.release_hold`; the service still checks ownership and current hold state. A decline is recorded without calling release.

In the deliberate control at the bottom of the file, the supplied identifier is replaced with `self.pending['proposal_id']` before this method runs. That small substitution makes the old answer apply to the new selection.

The identifier is a reference to a question, not an authentication token. The application must obtain the passenger’s identity and decision through an authenticated interface outside the model’s tool surface. Letting the model supply an `accept` string would not establish passenger permission.

## Inspect refusal and replay as separate outcomes

Use `tests/test_release_review.py` as the starting test plan for your integration:

- **Refusal:** the original hold remains active and inventory does not change. A later acceptance for that completed, declined question still returns the decline.
- **Correction:** an answer for a superseded question leaves both holds active. If that answer arrives late, retain the holds and present the current question for a fresh decision.
- **Replay:** accepting the current question releases its named hold once. Repeating it returns the stored receipt without returning another seat to inventory.

The existing service already handles a repeated release. Do not credit that service behaviour to the new question-binding check. Inspect both the receipt and inventory: either can expose a mistaken explanation of what made a test pass.

:::checkpoint{id="travel-approval-old-question" question="You change a release proposal from Miami to Chicago, then receive acceptance for the old Miami question. Which state should the bound controller preserve?" options="Both holds stay active|Only Chicago is released|Both holds are released" answer="0"}
The old question was retired when the proposal changed. Accepting it must not release either hold. Chicago needs a decision for its current question.
:::

:::solution{title="Extend the check to another state-changing tool"}
Choose a tool that can give up a resource, such as a reservation cancellation. Record the proposed operation and resource, then change the selection before delivering the old answer. Assert the final state of both resources.

Add a deliberately unbound control so the same sequence can expose the mistake. If both paths pass, inspect whether the negative control actually reaches the service or whether an unrelated check blocked it first.

Then refuse a proposal and send acceptance for that same completed question. The refusal must remain effective. Finally, repeat a successful current acceptance and inspect the resulting state and receipt. These cases distinguish question identity, completed decisions and service repeat handling.
:::

## Choose where the confirmation belongs

The [guide to outdated confirmations](/library/guides/confirmation-bound-to-the-revision-read-back/) checks a different part of the same problem: whether the terms read to the customer still match the record at execution. This exercise checks **which question** an answer belongs to. Use question identity to reject answers for a replaced proposal; check content or a record version when the terms can change under the same identifier. An integration may need both. This fixture does not detect a terms change that keeps the same hold and question identifiers.

This application controller costs an interface and a decision lifecycle that your team must own. Each proposal needs a structured decision; a correction retires the old question and needs another. These tests inject decisions directly, so they do not measure human understanding or the length of a deployed conversation.

Mantle’s native `requires_confirmation` declaration is another integration choice. The existing [characterisation tests](https://github.com/RasaHQ/rasa-community-resources/blob/5f6fa63c5a987c42461bf77ce6cc61576f940fef/examples/mantle-text-disruption-mode-gpt/tests/test_release_gate_characterisation.py) inspect the shipped hold declaration and compile temporary added or removed declarations when the SDK is installed. The retained offline proof skips those SDK-dependent checks. It does not execute a newly gated release or compare that native path with this controller.

Choose the mechanism and the owner of the question deliberately. Ask the engineer responsible for the integration to test refusal, changed choices and retries through the actual channel; have the conversation designer review the questions and receipts together.

:::callout{type="info" title="What remains before a real integration"}
The controller is an in-memory, single-process fixture. Persistence, expiry, concurrent requests and transport authentication are absent. Its sample question names a hold identifier; a passenger-facing interface also needs understandable flight details and the consequences of release. If terms can change under the same hold identifier, include those terms in your approval design and test their revision too.
:::

If a hold expires or the service rejects it, stop the proposed release and show the current service status before seeking another decision. Qualify those integration paths before using this exercise as a production approval mechanism.

The [agent-permissions guide](/library/guides/review-agent-permissions/) helps organise the wider review. The [Horizon output-guard investigation](/library/guides/output-guard-told-passengers-no-seat-held/) follows a separate problem: the service held a seat, but the fallback reply said otherwise.

:::cta{title="Test one approval after a correction" href="/library/guides/review-agent-permissions/" label="Review agent permissions"}
Pick one operation that can give up a resource. Send an old answer after changing the selection, then inspect the resource state and the receipt before accepting the integration.
:::