Rasa gives you a declarative way to say “this tool needs something first”:
tool_constraints:
- reissue_card:
requires_confirmation:
enabled: true
utter_for_confirmation: utter_confirm_reissue
This project uses it, and you should too. But be precise about what it controls.
Routing versus execution
The orchestrator evaluates tool_constraints against conversation state to
decide which tools the model is offered. That shapes the dialogue — the
caller gets asked to verify, instead of being told “no” for reasons they cannot
see. It is a genuinely useful control.
It is a routing control. It is not an execution control.
It does not run when the function runs. Consider what happens if:
- the YAML key is misspelled — it is silently ignored, no error at load;
- a future skill imports
reissue_cardwithout the constraint — it is simply absent; - the model is swapped for one that reasons differently — the offer set changes.
In every one of those cases the function is still entered, and the card is still posted. Nothing crashes. Nothing logs a refusal. The control was real and it was in the wrong layer.
So there are two layers, and they are not redundant
| Layer | Where | Nature | Can it be bypassed? |
|---|---|---|---|
tool_constraints | skill frontmatter | declarative, shapes the dialogue | yes |
guard_reissue() | inside the tool | imperative, gates the side effect | no |
The inner one looks like this, in tools/cards.py:
address = classify_address(line1, city, postcode, customer["addresses_on_file"])
# ---- the line before the side effect --------------------------------
try:
guard_reissue(address, auth_tier, on_file_since=on_file_since)
except ReissueRefused as exc:
return _as_dict(exc.outcome)
# ---------------------------------------------------------------------
reference = f"RC-{uuid.uuid4().hex[:8].upper()}"
Every function in that module that changes the world has the same shape: resolve inputs, classify provenance, guard, act, return an outcome. If you copy one thing from this tutorial, copy that ordering.
Why it raises instead of returning False
def guard_reissue(...) -> Decision:
decision = evaluate(...)
if decision.allowed:
return decision
raise ReissueRefused(...)
A boolean can be ignored by writing guard_reissue(...) on its own line and
carrying on — which reads, at a glance, exactly like calling a guard. Reviewers
skim past it. An exception cannot be ignored that way: the only route to the
line that posts the card is a return.
The test this makes possible
Because the binding check is a Python function, the guarantee is provable without a model:
make policy
An address supplied during the call:
✓ medium is NOT enough — this is the account-takeover path
-> ok=False result=step_up_required
No LLM, no sampling, no judge. This is a fact about the process.
That distinction matters more than it might seem. A conversation-level test would tell you the model asked for a code — that the usual path is usually taken. This tells you the unusual path is closed. Only the second one is a security claim, and only the second one stays true when the model changes.
