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.
- Sam Can a company blame its chatbot for a wrong answer?
- Priya Air Canada tried. In twenty twenty-two, after his grandmother died, Jake Moffatt asked the airline's website chatbot about bereavement fares. It told him he could apply for the discount after travelling. The real policy said he couldn't. A tribunal made the airline pay.
- Sam What exactly went wrong?
- Priya The chatbot suggested he could apply retroactively. He booked at full price, applied for the discount, and staff told him retroactive applications weren't allowed. Another page of the same website said so too, and the airline argued he should have found it.
- Sam What did the tribunal say?
- Priya Air Canada suggested, in effect, that the chatbot was a separate legal entity responsible for its own actions. The tribunal called that a remarkable submission. The chatbot is still just a part of Air Canada's website, and the airline is responsible for all of it. It makes no difference whether the information comes from a static page or a chatbot. The award was eight hundred and twelve dollars: the fare difference, interest and fees.
- Sam How would grounding have helped?
- Priya A grounded bot retrieves the policy passage first and answers only from it, with the citation. If no passage matches well enough, it says it can't confirm and hands over to a person, instead of guessing.
- Sam What's in the code?
- Priya A small grounded answerer in Python, TypeScript and Java. It answers the refund question from the policy and cites it, refuses a question the policy doesn't cover, and blocks a draft answer that says you can apply within ninety days, because nothing in the policy says that.
- Priya Your turn. With the threshold at zero, what goes wrong?
- Priya It always answers with the closest passage, even an irrelevant one. The threshold is what lets it refuse.
- Sam The lessons?
- Priya You own every answer. Ground answers in your own sources and cite them. Treat refusing as a feature. Check the draft's claims before sending. And build a test set from the questions that cost money.
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.
2. What the tribunal decided
| Question | The 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.
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
- You own every answer. To a customer, the chatbot is the company. Treat its output like any published statement (Module 18).
- Ground answers in your own sources. Retrieve the policy, answer from it, and show the citation (Module 15).
- Refusing is a feature. "I can't confirm that; here's a person" beats a confident guess. Set a threshold and test it.
- Check claims before sending. A second step that compares the draft against the sources catches invented details like "within 90 days" (Module 03).
- Evaluate on the questions that cost money. Refunds, eligibility and fees deserve their own test set (Module 17).
8. Your turn
- List the costly questions (10 min). For an assistant you know, which ten questions would cost money or trust if answered wrongly?
- Find the source for each (10 min). Which document answers each one? If none does, the assistant should refuse.
- Write the refusal (5 min). What does it say, and where does it send the customer?
- "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?
Which control would most directly have prevented the wrong answer?
Did it matter that the wrong information came from a chatbot rather than a web page?
10. Sources and further reading
| Resource | Why |
|---|---|
| Moffatt v. Air Canada, 2024 BCCRT 149 | The decision: every fact and quote on this page. |
| Modules 03, 15 and 17 | Why models invent answers, how grounding works, and how to test it. |