InterviewKata
AI engineering · Case study 03 · AI in the real world

Air Canada: you own what your chatbot says

In November 2022, after his grandmother died, Jake Moffatt asked Air Canada's website chatbot about bereavement fares. It told him he could apply for the discount after travelling. The airline's actual policy said he couldn't. When he claimed the difference, Air Canada argued it wasn't responsible for what its chatbot said. A tribunal called that "a remarkable submission" and made the airline pay. The amount was small; the principle wasn't.

A 2-minute conversation: Sam asks, Priya explains
0:00 / 2:06

1. The situation

Mr. Moffatt was booking flights after a death in the family. The decision records that "the chatbot suggested Mr. Moffatt could apply for bereavement fares retroactively." He booked at the full price, then applied for the bereavement discount. Air Canada's staff told him it "did not permit retroactive applications". Another page of the same website said so too, and the airline argued he should have found it.

Real caseFrom the tribunal's own decision: Moffatt v. Air Canada, 2024 BCCRT 149 (Civil Resolution Tribunal of British Columbia, 14 February 2024). The policy and chat in the demos below are invented, shaped like the case.

2. What the tribunal decided

QuestionThe tribunal's answer
Is the airline responsible for its chatbot?Yes. Air Canada suggested "the chatbot is a separate legal entity that is responsible for its own actions. This is a remarkable submission." The chatbot "is still just a part of Air Canada's website. It should be obvious to Air Canada that it is responsible for all the information on its website."
Does it matter that it was a chatbot, not a page?No: "It makes no difference whether the information comes from a static page or a chatbot."
Should the customer have checked another page?No: "There is no reason why Mr. Moffatt should know that one section of Air Canada's webpage is accurate, and another is not."
What was the legal basis?Negligent misrepresentation: the airline didn't take reasonable care that its chatbot's statements were accurate.
What did it cost?$812.02: $650.88 in damages (the fare difference), $36.14 in interest and $125 in tribunal fees.

3. How it went wrong, and how grounding prevents it

The same question asked of an ungrounded chatbot and of one that answers only from the policy.

4. The control in code

A grounded answerer. It finds the policy passage that best matches the question, answers with that passage and its id, and refuses when nothing matches well enough. A second check blocks a draft answer whose claims don't appear anywhere in the policy. Real systems use embeddings rather than shared words (Module 15), but the shape is the same. The program was run in all three languages; each prints the output below it.

Policy BF-1Bereavement fares must be requested before travel, when the ticket is booked.
Policy BF-2A bereavement fare cannot be claimed as a refund after travel has started.
Policy BG-1Each passenger may check one bag of up to 23 kg for free.
import re

# An invented airline's policy, split into passages with ids (shaped like the Air Canada case).
POLICY = {
    "BF-1": "Bereavement fares must be requested before travel, when the ticket is booked.",
    "BF-2": "A bereavement fare cannot be claimed as a refund after travel has started.",
    "BG-1": "Each passenger may check one bag of up to 23 kg for free.",
}
STOP = {"a", "an", "the", "of", "to", "is", "be", "can", "i", "my", "for", "after", "before", "it", "do", "you", "may", "up", "when", "has", "as", "on"}


def words(text):
    return {w for w in re.findall(r"[a-z0-9]+", text.lower()) if w not in STOP}


def retrieve(question):
    """The passage sharing the most words with the question (ties: lowest id), and the share of question words it covers."""
    q = words(question)
    best = max(sorted(POLICY), key=lambda pid: len(q & words(POLICY[pid])))
    return best, len(q & words(POLICY[best])) / max(1, len(q))


def answer(question, threshold=0.3):
    pid, score = retrieve(question)
    if score < threshold:
        return "I can't confirm that from our policy. Let me connect you with an agent."
    return f"{POLICY[pid]} [{pid}]"


def supported(draft):
    """Is every word that carries a claim in the draft found in some policy passage?"""
    known = set().union(*(words(p) for p in POLICY.values()))
    missing = sorted(words(draft) - known)
    return not missing, missing


for q in ["Can I claim a bereavement fare refund after my flight?", "How many bags can I check for free?", "Can I bring my dog in the cabin?"]:
    print(f"Q: {q}\nA: {answer(q)}")

draft = "Yes, you can apply for the bereavement fare within 90 days of the ticket date."
ok, missing = supported(draft)
print(f"draft check: {'supported' if ok else 'blocked'}; not in the policy: {', '.join(missing)}")
// An invented airline's policy, split into passages with ids (shaped like the Air Canada case).
const POLICY: Record<string, string> = {
  "BF-1": "Bereavement fares must be requested before travel, when the ticket is booked.",
  "BF-2": "A bereavement fare cannot be claimed as a refund after travel has started.",
  "BG-1": "Each passenger may check one bag of up to 23 kg for free.",
};
const STOP = new Set(["a", "an", "the", "of", "to", "is", "be", "can", "i", "my", "for", "after", "before", "it", "do", "you", "may", "up", "when", "has", "as", "on"]);

const words = (text: string) => new Set((text.toLowerCase().match(/[a-z0-9]+/g) ?? []).filter((w) => !STOP.has(w)));
const overlap = (a: Set<string>, b: Set<string>) => [...a].filter((w) => b.has(w)).length;

function retrieve(question: string): [string, number] {
  // The passage sharing the most words with the question (ties: lowest id), and the share of question words it covers.
  const q = words(question);
  let best = "", top = -1;
  for (const pid of Object.keys(POLICY).sort()) {
    const n = overlap(q, words(POLICY[pid]));
    if (n > top) { best = pid; top = n; }
  }
  return [best, top / Math.max(1, q.size)];
}

function answer(question: string, threshold = 0.3): string {
  const [pid, score] = retrieve(question);
  if (score < threshold) return "I can't confirm that from our policy. Let me connect you with an agent.";
  return `${POLICY[pid]} [${pid}]`;
}

function supported(draft: string): [boolean, string[]] {
  // Is every word that carries a claim in the draft found in some policy passage?
  const known = new Set(Object.values(POLICY).flatMap((p) => [...words(p)]));
  const missing = [...words(draft)].filter((w) => !known.has(w)).sort();
  return [missing.length === 0, missing];
}

for (const q of ["Can I claim a bereavement fare refund after my flight?", "How many bags can I check for free?", "Can I bring my dog in the cabin?"])
  console.log(`Q: ${q}\nA: ${answer(q)}`);

const draft = "Yes, you can apply for the bereavement fare within 90 days of the ticket date.";
const [ok, missing] = supported(draft);
console.log(`draft check: ${ok ? "supported" : "blocked"}; not in the policy: ${missing.join(", ")}`);
import java.util.*;
import java.util.regex.*;

public class Main {
    // An invented airline's policy, split into passages with ids (shaped like the Air Canada case).
    static final Map<String, String> POLICY = new TreeMap<>(Map.of(
        "BF-1", "Bereavement fares must be requested before travel, when the ticket is booked.",
        "BF-2", "A bereavement fare cannot be claimed as a refund after travel has started.",
        "BG-1", "Each passenger may check one bag of up to 23 kg for free."));
    static final Set<String> STOP = Set.of("a", "an", "the", "of", "to", "is", "be", "can", "i", "my", "for", "after", "before", "it", "do", "you", "may", "up", "when", "has", "as", "on");

    static Set<String> words(String text) {
        Set<String> out = new LinkedHashSet<>();
        Matcher m = Pattern.compile("[a-z0-9]+").matcher(text.toLowerCase());
        while (m.find()) if (!STOP.contains(m.group())) out.add(m.group());
        return out;
    }

    static int overlap(Set<String> a, Set<String> b) { int n = 0; for (String w : a) if (b.contains(w)) n++; return n; }

    static String answer(String question, double threshold) {
        // The passage sharing the most words with the question (ties: lowest id), and the share of question words it covers.
        Set<String> q = words(question);
        String best = ""; int top = -1;
        for (String pid : POLICY.keySet()) { int n = overlap(q, words(POLICY.get(pid))); if (n > top) { best = pid; top = n; } }
        double score = (double) top / Math.max(1, q.size());
        if (score < threshold) return "I can't confirm that from our policy. Let me connect you with an agent.";
        return POLICY.get(best) + " [" + best + "]";
    }

    static List<String> missing(String draft) {
        // Is every word that carries a claim in the draft found in some policy passage?
        Set<String> known = new HashSet<>();
        for (String p : POLICY.values()) known.addAll(words(p));
        List<String> out = new ArrayList<>();
        for (String w : words(draft)) if (!known.contains(w)) out.add(w);
        Collections.sort(out);
        return out;
    }

    public static void main(String[] args) {
        for (String q : List.of("Can I claim a bereavement fare refund after my flight?", "How many bags can I check for free?", "Can I bring my dog in the cabin?"))
            System.out.println("Q: " + q + "\nA: " + answer(q, 0.3));
        String draft = "Yes, you can apply for the bereavement fare within 90 days of the ticket date.";
        List<String> miss = missing(draft);
        System.out.println("draft check: " + (miss.isEmpty() ? "supported" : "blocked") + "; not in the policy: " + String.join(", ", miss));
    }
}
Q: Can I claim a bereavement fare refund after my flight?
A: A bereavement fare cannot be claimed as a refund after travel has started. [BF-2]
Q: How many bags can I check for free?
A: Each passenger may check one bag of up to 23 kg for free. [BG-1]
Q: Can I bring my dog in the cabin?
A: I can't confirm that from our policy. Let me connect you with an agent.
draft check: blocked; not in the policy: 90, apply, date, days, within, yes

5. Try it: grounding on and off

Ask the invented airline a question. With grounding off, the bot answers anything fluently, the way the real chatbot did. With grounding on, it answers only from the three policy passages above, and refuses below the match threshold.

6. What happened, and why it matters

Air Canada was ordered to pay within 14 days. The sum was small, but the decision is now one of the most cited examples of a basic rule for anyone building AI products: a company can't disclaim what its assistant tells customers. The tribunal didn't need any new law for AI. It applied ordinary negligent misrepresentation to a new kind of website component.

7. Lessons

8. Your turn

  1. List the costly questions (10 min). For an assistant you know, which ten questions would cost money or trust if answered wrongly?
  2. Find the source for each (10 min). Which document answers each one? If none does, the assistant should refuse.
  3. Write the refusal (5 min). What does it say, and where does it send the customer?
Say it in the interview
  • "How do you stop a support bot giving wrong policy answers?" Ground every answer in the policy with retrieval, cite the passage, refuse below a match threshold, and check the draft's claims against the sources before sending. Air Canada was held liable in 2024 for exactly this kind of wrong answer.

9. Check yourself

What did Air Canada argue about its chatbot?

The tribunal called this "a remarkable submission".

Which control would most directly have prevented the wrong answer?

Grounding and refusal keep the bot to what the policy says.

Did it matter that the wrong information came from a chatbot rather than a web page?

The company is responsible for all the information on its website.

10. Sources and further reading

ResourceWhy
Moffatt v. Air Canada, 2024 BCCRT 149The decision: every fact and quote on this page.
Modules 03, 15 and 17Why models invent answers, how grounding works, and how to test it.