Rate objections: from the PDF to policy ids
Source: docs/source/ct-rate-objection.pdf (text in docs/source/ct-rate-objection.txt), Stevan's training playbook. Method: never defend the rate; change what is being compared.
Approval. Stevan approved every quoted rep line in the PDF as final wording on 2026-09-25: the "Say" lines, the three no-quote beats, the four qualifying questions and the closing quote. They are in policy/src/playbook-*.json as status: "approved", approved_by: "stevan@currencytransfer.com", approved_at: "2026-09-25", source: "ct-rate-objection.pdf", locked: true (verbatim; never rewritten). The word "Say" is a label the UI shows once on the block, never part of a line.
Where each part of the PDF lives
Situations sit under a move's situations array. Ids are unique within a playbook. Point text_ids are <situation>.<n> and are stable. core_text_id names the line that shows the situation was handled.
| PDF section | Situation | Onboarding move | CS move | qualify_first | Core line | Do-not flags |
|---|---|---|---|---|---|---|
| Qualify the quote before you respond | qualify_the_quote |
handle_rate_objection |
handle_rate_complaint |
– | qualify_the_quote.4 |
rep_rubbished_competitor |
| No competing quote in hand | no_quote_in_hand |
handle_rate_objection |
handle_rate_complaint |
false | no_quote_in_hand.2 |
rep_improved_rate_unasked, rep_explained_spread, rep_said_standard_for_volume |
| Competitor named, number quoted | competitor_named |
handle_rate_objection |
handle_rate_complaint |
true | competitor_named.2 |
rep_rubbished_competitor |
| Same call, every trade | serial_shopper |
handle_alternative_provider |
handle_rate_complaint |
false | serial_shopper.1 |
rep_improved_rate_unasked |
| Comparing against a number nobody can trade | mid_market_comparison |
show_the_rate_against_mid_market |
handle_rate_complaint |
false | mid_market_comparison.2 |
rep_explained_spread |
| The incumbent has reacted | bank_matched |
handle_alternative_provider |
handle_rate_complaint |
false | bank_matched.1 |
rep_went_under_bank |
| If you do move on price | concession_terms |
handle_rate_objection |
handle_rate_complaint (+ situation_refs on offer_forward_for_known_future_payment) |
– | concession_terms.5 |
rep_improved_rate_unasked |
Each situation carries headline (the client's words), what / not_for, hint (the PDF's delivery guidance), points, do_not (the PDF's Do-not text, verbatim) and do_not_flags. All five rate moves above are rewrite_allowed: false.
Questions (all in policy/src/shared.json, routed in both rules files)
| Id | Type, role | Fires on |
|---|---|---|
rate_objection_type |
Choice, concern_type (after the scenario Choice) |
which of the five, or none |
rate_pressure_raised |
noul, window_fact (persist any) |
the client pushed back on the rate; gates the checklist item |
client_asked_for_better_rate |
noul, client_turn |
"Can you do better than that?" |
client_named_competitor_rate |
noul, client_turn |
"Company X gives me a better rate." |
client_cited_mid_market |
noul, client_turn |
"Google says 1.2740. Why am I getting 1.2690?" |
client_says_bank_matched |
noul, client_turn |
"I spoke to my bank and they've matched you." |
client_shops_every_trade |
noul, client_turn |
"I'll just check what the others are doing." |
rep_qualified_the_quote |
noul, rep_turn (persist rep) |
the rep asked one of the four questions |
rep_closed_with_live_quote |
noul, rep_turn (persist rep) |
"it's live, do you want it or shall I requote" |
rep_obtained_something_for_concession |
noul, rep_turn (persist rep) |
a price move tied to volume, consolidation, forward cover or an introduction |
rate_pressure_handled |
noul, must_say (persist rep) |
ticks the checklist item |
| 5 do-not nouls (below) | noul, risk (lock_on rep) |
latch a risk flag |
Rewrite verifiers (policy/src/verify.json, threshold 0.30 in both rules files): improves_rate_or_offers_discount (any price move, even conditional: only an approved line may offer one) and explains_spread.
Risk flags (the do-nots) and their live notes
| Flag | PDF Do-not | live_note |
|---|---|---|
rep_improved_rate_unasked |
Improve the rate to close a question that was not about the rate; a concession given for nothing | Careful: that moved the price for nothing. Get something on the call for it. |
rep_explained_spread |
Do not explain the pricing / how the spread is built | Careful: that explained our margin. Compare what lands instead. |
rep_said_standard_for_volume |
Do not say it is standard for their volume | Careful: 'standard for your volume' says somebody gets a better price. |
rep_rubbished_competitor |
Suggest they are wrong or lying; rubbish the competitor | Careful: running them down makes us sound like the expensive option. |
rep_went_under_bank |
Go under them | Careful: don't go under the bank. Test whether their price survives. |
Live notes are drafts for Stevan (risk_flags[].live_note, the field DESIGN.md names).
Checklist and gating
rate_pressure_handled("Rate pushback handled"), both scenarios,applies: {when_fact: "rate_pressure_raised"}, so it is n/a on calls with no rate pushback. M1 ticks it from the same-id noul on a rep turn. A latched do-not flag shows beside it but cannot untick it, because nothing un-persists. M2 rule (met_whenin the rules file):rep_qualified_the_quote, or thecore_text_idof the situationrate_objection_typepicked, was said, and none of the five rate flags latched.rate_objection_typegating. Read it only while the open concern type israte,fees("price") oralternative_provider(the signal'swhen_concern). The Choice also answersnoneby itself when there is no rate pushback.concern.tsreads the firstconcern_typesignal, so the entry must stay after the scenario Choice. The rate question group (COPILOT-081) asks the rate questions only while a rate-type concern is open, and the engine's rate episode (src/engine/rate.ts, COPILOT-086) follows the situation from this Choice: it opens one decision after the concern, re-types in place and closes with the concern. Showqualify_the_quotefirst when the situation hasqualify_first: true, then the situation's points. Showconcession_termswhen a price move is on the table (SessionState.rate.concession: a rep turn in the episode withrep_obtained_something_for_concessionorrep_improved_rate_unasked≥signal_on).
Lines (verbatim; approved 2026-09-25 unless marked)
| text_id | Line |
|---|---|
qualify_the_quote.1 |
When did you get that rate? |
qualify_the_quote.2 |
Was that a live quote on your amount, or a headline off a website? |
qualify_the_quote.3 |
What were the fees on top, including the receiving bank's cut? |
qualify_the_quote.4 |
What actually landed in the beneficiary account last time, against what you were quoted? |
no_quote_in_hand.1 |
Better than what? |
no_quote_in_hand.2 |
No. That's the price. |
no_quote_in_hand.3 |
And it's live, so it's moving while we talk. Do you want it, or shall I requote in ten? |
competitor_named.1 |
They probably do on the first one. What matters is what you're getting on trade twenty, when nobody's watching. |
competitor_named.2 |
Pull your last three confirmations from them and let's compare what landed, not what was quoted. |
serial_shopper.1 |
Happy to quote you every time. If you want a better number without the ring-around, give me the flow for the quarter and I'll price it properly. |
mid_market_comparison.1 |
That's the mid-market rate. It's the midpoint between buying and selling, so it's a reference number rather than a price. Nobody trades at it, banks included. |
mid_market_comparison.2 |
The number worth comparing is what actually lands from us against what actually lands from your bank, same day, same amount. |
bank_matched.1 |
Good. That's the first time they've sharpened up for you. Ask them to hold it in writing for the next twelve months and see what comes back. |
bank_matched.2 |
Worth looking at what they were giving you before we started quoting. That's their real price, the one that applies when nobody's pushing. |
concession_terms.1 (draft) |
Volume. A monthly or quarterly commitment that puts them in the band you are pricing them at. |
concession_terms.2 (draft) |
Consolidation. The corridors they currently run elsewhere come to you. |
concession_terms.3 (draft) |
Forward cover. The next tranche booked forward rather than left to shop. |
concession_terms.4 (draft) |
Introduction. The finance contact at the group company, the supplier, the sister office. |
concession_terms.5 |
The rate is one number on one day. What you're paying for is knowing what it'll be worth when it lands and when it'll get there. If you press send and then spend two days chasing, the half pip you saved cost you more than it was worth. |
The same ids appear in both playbooks with identical text. That is 15 approved and 4 draft points per playbook.
Drafted lines retired (status: "retired", each with a note naming the rule broken)
Onboarding: show_the_rate_against_mid_market.1, .2, .3; handle_rate_objection.1, .2, .3; handle_alternative_provider.1. CS: handle_rate_complaint.1, .2, .3. Kept as draft, because they fit the framework: handle_alternative_provider.2 (an open question) and .3 ("compare the settled amounts"). A retired line is still refused by the loader like any unapproved line. Delete it once you agree.
Needs Stevan's ruling
said_rate_transparencyvs the PDF. The onboarding checklist item applies on every call: mid-market is shown next to your rate, there is no transfer fee, and the margin is inside the rate. The PDF says never explain the spread and never make mid-market the benchmark. I retired all three lines that deliver the item. Do you want to drop or reword it, or keep it for neutral fee questions only (and un-retire.3)?- Core lines. Are the
core_text_ids in the table above right? The biggest choice isno_quote_in_hand.2("No. That's the price.") over.1. - qualify_first. The PDF sets it only for a named competitor. Should
bank_matched(a matched rate is also a competing number) be qualified first too? - Headlines. The mid-market headline drops the PDF's numbers, because a concrete rate is refused in approved text. The
bank_matchedheadline is 9 words, against DESIGN.md's 8-word cap. - Concession terms 1-4 are descriptions, not quoted lines, so they are still draft. Approve them as points?
- Alerts. Does a market order count as "forward cover"? Only the CS forward move is linked for now.
offer_alert_or_market_order.2("What level would you be happy at?") is shown during CS rate concerns, and it could sound like "name your price". - Judgement retirements. Check
handle_alternative_provider.1("fine for small amounts") andshow_the_rate_against_mid_market.2(a bank quote against mid-market). rep_improved_rate_unaskedis read as "moved on price without getting something back", not "moved on price without the client asking". Is that the right reading?- Authority. The PDF does not say who may move on price, or by how much ("I'll price it properly").
Stevan's rulings (2026-09-25)
- Rate transparency stays a must-say, narrowed to "the rate you see is the rate you get, no transfer fee"; the mid-market correction line handles comparisons; no line may explain how the spread is built.
- The four concession terms are approved as points.
- "My bank has matched you" runs without the qualifying sub-checklist.
- Forward cover means a forward contract only; the alert line is never shown while a rate concern is open (
blocked_by: rate_pressure_raisedon the alert moves). - No guidance yet on who may move on price or by how much; the concession flag only reports a concession that obtained nothing. To be revisited.
Live probe findings (COPILOT-086, 2026-09-25, jev-1.13.0)
After the rep quote "Today's rate on your 20,000 is 1.2690.", each of the five client lines picked its own situation with confidence ≥ 0.95. For the red-pen:
- Two lines do not open a concern on their own.
client_objectingscored 0.41 on "I'll just check what the others are doing." and 0.17 on "I spoke to my bank and they've matched you.", both underconcern_open(0.60). The rate question group is asked only while a rate-type concern is open, so on these two lines the episode waits for a turn that does open one. The other three scored 0.84 to 0.96. rep_improved_rate_unaskedfires on any price move. "Let me see if I can take a few pips off that for you." scored 0.86 after the quote alone and 0.80 after "Can you do better than that?" (the client did ask). "No. That's the price." scored 0.02. So Jev reads the flag as any price move, asked for or not, and without checking whether something was obtained (question 8 above asks for the second reading). The engine treats it as a price move on the table (concession).
L1 rate cases (COPILOT-092, 2026-09-25, jev-1.13.0)
eval/labelled/rate.json holds 25 cases (13 onboarding, 12 CS). node scripts/probe-questions.mjs --cases eval/labelled/rate.json passed 24/25 cases and 46/47 checks. For the red-pen:
rep_rubbished_competitorreads the mid-market correction as a put-down. The approved linemid_market_comparison.1("That's the mid-market rate. … Nobody trades at it, banks included.") scored 0.50, above the 0.40 negative bar. Criteria repair: add "explains that the mid-market rate is a reference number nobody trades at" to thefalsecriterion, asrep_explained_spreadalready does.- Near the line:
competitor_named.1("They probably do on the first one. …") scored 0.33 onrep_rubbished_competitor. It passes, but the margin is thin. Thefalsecriterion already quotes it. - The two client lines that open no concern on their own (serial shopper, bank matched; see above) still pick their situation (0.96 to 0.98) and their client noul (0.85 to 0.94) when asked directly.