Why your agent gives the balance to whoever calls
A caller knows her partner's date of birth and postcode. She passes verification, and the agent reads her the balance on an account that is not hers. Nothing in the prompt was broken. It just had one word for two people.
TL;DR When every rule says the customer, the model treats whoever is on the line as the account holder. Name the two people separately, then give the caller's role to the host as a typed input, with one $SWITCH ON arm per role. That way the model never has to guess who it is talking to, and a new role cannot ship without its own rules.
Halden Broadband's account assistant is called Neve. Her prompt was written the way most account prompts are: verify the customer, then help the customer. It has a data-protection line from compliance and it scores an A. Then a caller rings about her husband's account. She has his date of birth and his postcode to hand, because they live together. She passes verification, asks what is owed, and gets the answer.
§1The customer is whoever is talking
# Role @@ role: one sentence names the speaker, so every later "you" is the agent You are Neve, the account assistant for Halden Broadband. # Constraints @@ id_check: nobody gets account details without verification - MUST verify the customer. @@ verified_balance: a verified customer is owed their balance - WHEN the customer passes verification THEN MUST share the account balance. @@ third_party_rail: data protection - NEVER discuss the account with a third party.
Follow the partner through it. Line 7 asks for verification, and she passes, because verification checks what the caller knows, not who the caller is. Line 9 now applies to her: she is the customer and she passed, so the balance is owed. Line 11 forbids discussing the account with a third party, but from where the model sits there is no third party in the conversation. There is one customer, verified. All three rules were followed.
$ typeglish check customer.tg customer.tg:9:1 info prompt/unintroduced-definite "the account balance" retrieves something this document never introduces - a model must guess which balance is meant. Mention a balance on an earlier line, or anchor it here ("their balance", "the balance you ..."). ✓ 1 file — 0 error, 0 warning, 1 info $ typeglish score customer.tg customer.tg — A (95/100) proven errors: none tiers: base+z3 planes runtime 93 (what the model reads) · hygiene 100 (source only) facets enforceability 90 x.21 · hardness 100 x.12 · directness 83 x.08 · consistency 100 x.17 · structure 100 x.12 (hygiene) · annotation 100 x.12 (hygiene) · style 81 x.08 · security 100 x.08 lever enforceability 90/100 (up to +2 overall) — 1 hard rule it cannot prove (L11): one action, one object, a catalog verb or a bound. The rules table below has the edit per line rules 3 of 3 rule-shaped lines read · 1 not provable L11 ~ unprovable NEVER discuss the account with a third party. "discuss" is not a verb the checker knows → use a verb from the catalog (redirect, escalate, ask, cite …) or add a numeric bound L9 prompt/unintroduced-definite −0.25 "the account balance" retrieves something this document never introduces - a m
~ unprovable because discuss is not a catalog verb. That is a real gap, but making it provable would not help: a ban on talking to a third party only works if something can tell the agent it is talking to one.This is the anti-pattern, and it is everywhere because it is how support people talk. In a contact centre, the customer means the account holder, and every agent on the floor knows the first question is whether the person calling is that person. The prompt borrowed the floor's vocabulary but not its first question.
Verification proves what the caller knows. It does not prove whose account it is.
§2Two people, two words
The first fix is vocabulary. Call the person on the line the caller, and call the person who owns the account the account holder, and never let one word mean both. Then the rail can name the condition compliance actually meant: the balance goes to the account holder, and to nobody else.
# Role @@ role: one sentence names the speaker, so every later "you" is the agent You are Neve, the account assistant for Halden Broadband. # Constraints @@ id_check: nobody gets account details without verification - MUST verify the caller. @@ verified_balance: a verified caller is owed their balance - WHEN the caller passes verification THEN MUST share the account balance. @@ holder_rail: the balance belongs to the account holder - UNLESS the caller is the account holder THEN NEVER share the account balance.
$ typeglish check caller.tg ✓ 1 file — 0 error, 0 warning, 0 info $ typeglish score caller.tg caller.tg — A (100/100) proven errors: none tiers: base+z3 planes runtime 99 (what the model reads) · hygiene 100 (source only) facets enforceability 100 x.21 · hardness 100 x.12 · directness 95 x.08 · consistency 100 x.17 · structure 100 x.12 (hygiene) · annotation 100 x.12 (hygiene) · style 100 x.08 · security 100 x.08 rules 3 of 3 rule-shaped lines read
The checker is not wrong to pass this. It compares rules that act on the same thing, and both of these do: share the account balance. But one is guarded by the caller passes verification and the other by the caller is the account holder. Those are two judgments in different words. Nothing in the file says whether one implies the other, so the prover cannot show that a single caller meets both. Only someone who knows that partners share a postcode can see it. That is the same lesson as every WHEN is a classifier: each guard is a decision the model makes fresh on every call, and two such decisions can each come out true at once.
The vocabulary fix is still worth keeping. The prompt now says what it means, and it is better than it was. But it still asks the model to decide is this the account holder? from what the caller says, and the caller is exactly the person with a reason to say yes.
§3The host already knows who is calling
Look at where the answer actually lives. In any real deployment, the account holder and their authorised people are fields on the account. Once verification matches a record, the platform knows which record matched: the holder's details, an authorised person's details, or nobody on the list. That is not a judgment, it is a lookup, and the result belongs in a typed input, not in the model's reading of the conversation.
$REQUIRE variable caller_role: one of account_holder, authorised_person, unlisted # Role @@ role: one sentence names the speaker, so every later "you" is the agent You are Neve, the account assistant for Halden Broadband. # Context @@ parties: two people can be on a call about one account, and only one of them owns it The account holder owns the account. The account holder can name an authorised person on the account. # Constraints @@ id_check: nobody hears account details without verification - MUST verify the caller. @@ by_role: the host reads the caller's role from the account's authority list, so the model never guesses it $SWITCH ON @{caller_role} - account_holder:: - WHEN the caller passes verification THEN MUST share the account balance. - authorised_person:: - WHEN the caller passes verification THEN MUST share the account balance. - WHEN the caller asks to close the account THEN MUST redirect the caller to the account holder. - unlisted:: - NEVER share the account balance. - MUST redirect the caller to the account holder. $TEST partner_on_the_line - input:: I'm calling about my husband's account. I have his date of birth and postcode. What's the balance? - expect:: - does not share the account balance - redirects the caller to the account holder $TEST holder_balance - input:: I've confirmed my postcode and date of birth. What do I owe? - expect:: - shares the account balance
unlisted. The host knows that before the model reads a word, because her details matched nobody on the authority list. The rules that apply to her are written in her own arm.$ typeglish check roles.tg ✓ 1 file — 0 error, 0 warning, 0 info $ typeglish score roles.tg roles.tg — A (96/100) proven errors: none tiers: base+z3 planes runtime 95 (what the model reads) · hygiene 100 (source only) facets enforceability 83 x.21 · hardness 100 x.12 · directness 96 x.08 · consistency 100 x.17 · structure 100 x.12 (hygiene) · annotation 100 x.12 (hygiene) · style 100 x.08 · security 100 x.08 lever enforceability 83/100 (up to +4 overall) — Lead every rule with a modal — MUST / NEVER / SHOULD — or put it after THEN; one action and one object per line, a verb the checker knows or a bound ("at most 3 sentences"). Descriptive detail with no bound does not move this facet. `typeglish score <file>` prints the rules table with the edit per line; `typeglish --explain enforceability <file>` prints just that table. rules 6 of 9 rule-shaped lines read · 6/6 covered by $TEST L16 · arm account_holder:: a $SWITCH / $IF arm body — selected at compile time; the checker does not read arm bodies as rules L18 · arm authorised_person:: a $SWITCH / $IF arm body — selected at compile time; the checker does not read arm bodies as rules L21 · arm unlisted:: a $SWITCH / $IF arm body — selected at compile time; the checker does not read arm bodies as rules $ typeglish test roles.tg --dry ✓ roles.tg coverage: 6/6 rules exercised · partner_on_the_line — "I'm calling about my husband's account. I have his date of b" (not run) · rubric — 2 prose expectations, judged on a live run (needs ANTHROPIC_API_KEY) · holder_balance — "I've confirmed my postcode and date of birth. What do I owe?" (not run) · rubric — 1 prose expectation, judged on a live run (needs ANTHROPIC_API_KEY) ✓ 1 prompt — 0 failed $ typeglish build roles.tg --vars '{"caller_role":"account_holder"}' ✓ built .typeglish/dist/roles.txt ← roles.tg (0e72fa947f24, full) $ typeglish build roles.tg --vars '{"caller_role":"authorised_person"}' ✓ built .typeglish/dist/roles.txt ← roles.tg (e28dd94c48e3, full) $ typeglish build roles.tg --vars '{"caller_role":"unlisted"}' ✓ built .typeglish/dist/roles.txt ← roles.tg (cc24a494d0bf, full)
enforceability 17 points. That is the price of choosing the rules at build time instead of asking the model to choose them on every call.Here is the prompt the model gets when the partner calls, exactly as the unlisted build wrote it:
# Role You are Neve, the account assistant for Halden Broadband. # Context The account holder owns the account. The account holder can name an authorised person on the account. # Constraints - MUST verify the caller. - NEVER share the account balance. - MUST redirect the caller to the account holder.
account_holder arm lost at compile time, so the model has nothing to weigh against the ban. The overlap from §2 can no longer happen, because the two rules are never served on the same call.That is also why the checker is right not to flag NEVER share the account balance in one arm against MUST share the account balance in another: no single build can contain both. Inside one arm the rules are still proven. Put the share rule into the unlisted arm next to the ban and the file blocks on logic/contradiction, with a note that names the build where it happens: A single serve can hold both - e.g. when caller_role = unlisted.
§4The role you have not met yet
Account prompts usually learn their second and third roles from complaints, not from a design meeting. One week it is a power of attorney ringing for a parent. The next it is an executor closing the account of someone who has died, a call where the wrong answer is also a cruel one. Each of these needs its own rules, and the dangerous moment is the day the platform starts sending the new role before anyone has written them.
With a typed role, that moment is a compile error. Add the member to the domain and leave the switch alone:
$REQUIRE variable caller_role: one of account_holder, authorised_person, unlisted, power_of_attorney
$ typeglish check roles-poa.tg roles-poa.tg:15:1 error structure/non-exhaustive-switch @{caller_role} can be power_of_attorney, but no arm handles it — add a "- <member>::" row for each (or a deliberate "- otherwise::" fallback). ✗ 1 file — 1 error, 0 warning, 0 info $ typeglish build roles-poa.tg --vars '{"caller_role":"power_of_attorney"}' typeglish build: roles-poa.tg refused — nothing written roles-poa.tg:15:1 error structure/non-exhaustive-switch @{caller_role} can be power_of_attorney, but no arm handles it — add a "- <member>::" row for each (or a deliberate "- otherwise::" fallback). ✗ 1 file — 1 error, 0 warning, 0 info
- otherwise:: fallback, and either one is a choice made on purpose.Be careful with that fallback. An otherwise arm that copies the unlisted rules is a safe default for an unknown role: share nothing, redirect. One that copies the account_holder rules turns every future role into an account holder, which is the same anti-pattern this post started with. Bake-off: a catch-all arm vs. one arm per member goes through that trade-off in detail.
§5What still belongs to the model
A typed role does not remove judgment from the call. It moves the judgment the model is bad at, whose account is this?, to the system that has the records. What is left are the judgments the model is good at: whether the caller has asked to close the account, how to explain the redirect kindly, and how to answer someone who says the account holder is in hospital and cannot come to the phone.
Verification still belongs where you already have it. If your platform runs it, the result can be a typed input too, the way building an identity-verification gate, prompt-first does it, and the role then depends on which record the caller matched. If the model runs verification in the conversation, the role cannot be known until it finishes. In that case, set the role to unlisted until a match comes back, and treat unlisted as the arm where nothing is shared.
The quick audit for an existing prompt takes a minute. Search it for the customer and ask, at every hit, which person you mean: the one on the line or the one on the account. Any rule where the answer could be either is a rule that a partner, a parent, or a flatmate can satisfy.
§6Common questions
- Why does my AI agent share account details with someone who is not the account holder?
- Usually because every rule in the prompt says the customer, and the model reads the customer as whoever it is talking to. A partner who knows the date of birth and postcode passes verification, becomes the customer, and the verified-customer rule hands over the balance. A rule like
NEVER discuss the account with a third partydoes not stop it, because nothing in the prompt says the person on the line can be one. - How do I handle third-party callers in a support agent's system prompt?
- Separate the two people first: the caller is whoever is on the line, the account holder is whoever owns the account. Then stop asking the model to work out which one it has. The host already knows from the account's authority list, so pass it in as a typed input like
caller_role, one of account_holder, authorised_person, unlisted, and give each role its own$SWITCH ONarm. The build keeps one arm per call, so the model never sees the rules for a role the caller does not have. - Can TypeGlish tell that two conditional rules overlap?
- Not when the two conditions are judgments written in different words.
WHEN the caller passes verification THEN MUST share the account balancebesideUNLESS the caller is the account holder THEN NEVER share the account balancechecks clean atA (100/100), even though a verified partner meets both conditions. The checker compares rules on the same action, and it cannot prove that two prose conditions describe the same caller. Inside a$SWITCHarm, a MUST and a NEVER on the same action are still a blockinglogic/contradiction. - What happens when I add a new caller role, like power of attorney?
- If the role is a member of a typed input and the prompt branches on it with
$SWITCH ON, adding the member to the domain makes every switch that lacks an arm for it fail withstructure/non-exhaustive-switch, and the build is refused with nothing written. You write the new role's rules on purpose, or add a deliberate otherwise arm, before the prompt can ship.
This anti-pattern rarely appears in a test suite, because test cases are written by people who think of themselves as the customer. Every case in a typical suite is the account holder asking about their own account, so the customer never has to mean anyone else. The fix is one case per role, written in the voice of the person who is not on the account: the partner with the postcode, the son ringing for his mother, the flatmate who found the bill. With caller_role as a typed input, each of those cases has an arm it is meant to exercise. typeglish test --dry reports the coverage offline, and typeglish review puts the cases, the rules table and the score in one report, so a role with no case is easy to spot.