Skip to content

tutorial

Chapter 3 of 6

Chapter 3 — Put the check where it binds

by Rod Rivera Published

tool_constraints shapes the conversation and can be bypassed. The check that actually stops the card runs inside the function, on the line before the side effect.

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_card without 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

LayerWhereNatureCan it be bypassed?
tool_constraintsskill frontmatterdeclarative, shapes the dialogueyes
guard_reissue()inside the toolimperative, gates the side effectno

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.