Jev call copilot CurrencyTransfer research and architecture built 2026-09-27

Like-for-like: a line is shown only in the situation it was said in

Status: policy restructure done 2026-09-25 (policy/src/playbook-onboarding.json, policy/src/playbook-customer-success.json, regenerated policy/REDPEN.md). COPILOT-095 (2026-09-26) landed the data contract, the builder checks, the generated fits:: and situation:: questions, the red-pen sheet and the regenerated fixtures, and probed the questions live (§3.6). COPILOT-095b (2026-09-26) landed the engine gate (src/engine/situation.ts, plan.ts; §3.7). COPILOT-097 (2026-09-26) landed the slot requirements in the builder and the engine and gated where_is_my_money (§3.8); the live rendering is COPILOT-097b. Companion to docs/policy/rate-objections.md.

1. The rule

Stevan's rule: an approved playbook line may be suggested only when the client's actual question or situation matches the one the line was said in (harvested lines) or written for (drafted and PDF lines). Belonging to the same move is not enough.

The failure it prevents: customer success agree_next_step held two approved harvested lines, "You should be able to book the trade now." and "You should have the delivery date; just choose the next day that's available.", and the card could show the delivery-date line whenever the move was current, although the client's issue had nothing to do with delivery dates. The harvest shows what each line was actually said in:

Line What the client had just said (harvest, pseudonymised)
agree_next_step.h1 "You should be able to book the trade now." "But what I'm gonna do now is just log on and then try to reset up the two factor authentication and do the trade that I wanted to do." (a login or two-factor block had just been fixed)
agree_next_step.h2 "You should have the delivery date; just choose the next day that's available." "Oh, okay. Okay. So I have to request for Monday. Right?", "to be quick. Please remind me how does it start? That's in the website." (the client was on the booking screen, choosing a date)

They are two different client situations. They are now two situations, and neither line can be shown unless its own situation fits.

2. What changed in the policy

Every approved line of both playbooks now sits under a situations[] entry of its own move, and so does every rate situation point. A move's flat lines[] keep only unapproved drafts (and the retired lines kept for the record). Nothing was renamed: every text_id is unchanged, and a moved line keeps every field it had (approved_by, approved_at, source, note, applies, locked).

Onboarding Customer success
Situations 24 (17 new, 7 rate) 25 (18 new, 7 rate)
Approved situation points 40 (21 moved lines, 19 rate points) 45 (26 moved lines, 19 rate points)
Approved flat lines 0 (was 21) 0 (was 26)
Client examples 60: 43 harvest, 11 drafted, 6 PDF 57: 28 harvest, 23 drafted, 6 PDF

2.1 Situation fields

Field Meaning
id Unique in the playbook. The generated questions use it: fits::<id>, and an option of situation::<move>.
headline The client's situation in their own words, 8 words or fewer, as approved text situation.<id>.headline (draft until Stevan red-pens it).
what A literal description of when the situation applies, written for jev-1.13: the exact conditions in the client's (and where needed the rep's) turns, no inference about intent. It is the body of the fits:: noul and of the Choice option.
not_for The nearest situations it must not fire in: the same move's other situations, the neighbouring move's, and Stevan's delivery-date case where it is the neighbour. It becomes the false criterion of fits:: and the option's not_for.
client_examples [{text, source}], 2 or 3 client turns. harvest: a real, already pseudonymised client turn from data/analysis/harvest/turns.jsonl (verbatim substring). drafted: a plausible turn written for a drafted line (or where the harvest had no clean turn). ct-rate-objection.pdf: the playbook's own client line. They feed the red-pen and the L1 cases (COPILOT-096); they are never sent to Jev by the gate.
applies Optional, the plan grammar (PlanApplies), inherited from the line (explain_who_holds_the_money.h2's partner gating) or from its placeholders ({country} lines only for a non-UK residency). Absent = always.
hint Delivery guidance, approved text situation.<id>.hint (the builder requires one per situation; all new hints are drafts).
qualify_first, core_text_id, points, do_not, do_not_flags As for the rate situations (COPILOT-083). New situations have qualify_first: false and no do-nots.

Per point, two optional fields describe placeholders (COPILOT-097):

Every {…} or […] token of an approved point is covered by exactly one of the two; since COPILOT-097 buildPolicy refuses a bundle where it is not (§3.8).

2.2 How lines were grouped

The harvest decided it: for each harvested line, the client turn right before each rep turn in its cluster (turns.jsonl prev), or when that was only "Yeah." the nearest earlier client turn in the same call, and where a cluster's calls had no clean client turn, a matching turn from another harvested call. Lines whose client turns differ became separate situations; lines said in the same client situation stayed together as alternates (all shown, in order).

Split (one situation per client situation):

Kept together as alternates: pin_down_timing_and_amount h1 and h2 (both said after the client described plans without an amount); explain_booking_is_binding h1 and h2 (both said while walking the client through quoting and booking); the three status lines of give_status_with_specifics (one client question; which line is true depends on the trade status only the rep sees, so the hint says to read only the matching one); offer_alert_or_market_order h1, .1 and .3; reengage_dormant_with_a_reason .1 and .3; accept_no_need_gracefully .1 and .3.

The seven rate situations keep their points and headlines. Their what was rewritten as literal conditions (the fits:: noul reads it); the PDF's framing that was in what ("This is a reflex, not a demand …") moved to the situation's note, word for word.

One draft line was added with the restructure, set_up_recurring_or_batch.4 "Do you pay the same people every month, or several at once?", only because buildPolicy (assertPlanTemplates) and tests/unit/policy-src.test.ts then required a flat line per move. COPILOT-095 lets a situation point satisfy both and deleted it; the move now has no flat line.

2.3 Pseudonymisation

Harvest client examples are verbatim substrings of turns.jsonl, which the harvest had already pseudonymised ([NAME], [CLIENT], [COMPANY], numbers); drafted examples were written without any. None carries a person's name, a phone number, an email or an account number: every new string passed assertRedactedText (digit runs, IBAN, email, postcode) and the redactor's name pass (the only flags were sentence-initial words such as "Currently"). Call ids appear only in this document (§8 provenance), never in policy/src: a 10-digit id in a policy string would trip the fixtures' digit_run guard.

3. The gate (COPILOT-095)

3.1 The questions

Both are generated from the playbook like covered:: (COPILOT-089), not stored in shared.json. That was the brief's first option, and it was not taken, for three reasons: every bank question is sent on every full request today (there is no code gate, and question_groups live in rules-*.json), so a stored fits:: noul per situation (49 of them) would be asked on every decision until COPILOT-095 lands; tests/unit/policy-src.test.ts requires the bank ids and the rules-*.json signal ids to be the same set, so stored questions without signal entries fail it; and generating them from what/not_for keeps one copy of the wording, the way covered:: reads the point text. mergeBanks refuses bank ids starting with fits:: or situation::, as it refuses covered:: (COPILOT-095).

fits::<situation_id> (noul), one per candidate situation:

{
  "type": "noul",
  "instructions": "Does the client's latest turn, or a concern the client raised in `recent_transcript` that is still unanswered, match this situation? The situation: \"<what>\"",
  "criteria": {
    "true": "Every condition of the situation holds now, as shown by the turns in `recent_transcript`; the client's words, not the topic of the call, decide it.",
    "false": "A condition does not hold, the client is talking about something else, or only a nearby situation is happening: <not_for>"
  }
}

situation::<move> (Choice), only when the current item has two or more candidate situations:

{
  "type": "choice",
  "instructions": {
    "question": "Which of these situations is the client in right now, judged from the client's latest turn and any concern the client raised in `recent_transcript` that is still unanswered? Choose `none` if none of them is happening.",
    "focus": "Match the client's own words against each situation's `what`; respect each `not_for`. Belonging to the same topic is not enough."
  },
  "criteria": {
    "<situation id>": { "what": "<what>", "not_for": "<not_for>" },
    "none": { "what": "None of these situations is happening now: the client is on another topic, or has not said anything these describe." }
  }
}

The rate items keep COPILOT-083's situation Choice, rate_objection_type in policy/src/shared.json. There is no per-move situation::<move> Choice in the code today; the rate Choice is the one 083 introduced, and it keeps its none option ("The client has not pushed back on the rate or price."), checked in shared.json on 2026-09-25. The generated situation::<move> Choice must always carry none as well: it is what lets the gate answer "no situation" instead of forcing the least bad one.

3.2 Which situations, and when

3.3 Thresholds and the decision rule

New policy thresholds in rules-*.json (hashed under weights_hash, so a change is absorbed by recompute): situation_fit: 0.6 and situation_min_conf: 0.4.

For the current item, on every decision, with C its candidates and f(S) the latest fits::S answer:

  1. One candidate S: show S when f(S) ≥ 0.60.
  2. Two or more: take the Choice's pick c (situation::<move>, or rate_objection_type for the rate item). Show c when c ≠ none, its confidence ≥ 0.40 and f(c) ≥ 0.60. The two answers must agree; if the Choice picks one situation and a different one fits, nothing is shown.
  3. Otherwise show nothing: the item keeps its title and shows "Listening", never a point. There is no fallback to the move's flat lines or to "the first situation with an approved point" for a move that has situations (pointsOf's fallback stays only for a move without situations).
  4. A shown situation shows its approved points in playbook order, minus any point whose slot_requirements are not all known (COPILOT-097). If that leaves none, it is "Listening" too.
  5. Rate item: when the shown situation has qualify_first, the "Ask first" block (qualify_the_quote) shows with it; concession_terms joins while a price move is on the table (unchanged). If no rate situation fits, the item keeps its "Rate pressure" title and shows "Listening".
  6. covered:: is asked only for the points on screen, and delivered counts only points that were shown.

3.4 Budget

Measured with the policy-build worst case (a full 12-utterance window, every fact known, a concern open) on bundles built from the restructured sources:

Request Onboarding Customer success
Today's shape: bank + next_move + the 8 longest phrasing:: Choices (fixture-approved drafts) 10,489 8,978
Same, plus 3 fits:: and one situation:: Choice 11,742 (over the 11,000 soft budget) 10,133
No phrasing:: Choice for a move with situations (every move now has them), plus 3 fits:: and one situation:: Choice 10,210 8,804

One fits:: noul costs 246 to 292 tokens, one situation:: Choice 361 to 397. So COPILOT-095 must also drop phrasing:: for any move that has situations (its points come from the situation, as 083 already does for the rate moves). The code-rule drop order gains fits after all_phrasing and before window_to_8; a dropped fits:: is "unknown", which shows "Listening".

Measured in the engine (COPILOT-095b, tests/unit/policy-build.test.ts and tests/unit/engine-groups.test.ts, which now add the 3 longest fits:: nouls and the longest situation:: Choice of the fixture playbook to the worst case): onboarding 10,270, customer success 9,263 (policy-build) and 10,270 / 9,036 through planRequest (engine-groups), all ≤ 11,000 with nothing dropped.

With the rate group active the gate does not fit (open, §7 point 10). The worst case above has no rate-type concern open. With one open, the 16 rate questions join the request (COPILOT-081), and the fixture bundles measure (bank + next_move, state at the worst-case window):

Request, rate concern open Onboarding Customer success
Window 12, no gate 11,902 10,591
Window 12, + fits:: of the episode's situation only 12,127 10,817
Window 12, + 3 fits:: (the rule above) 12,602 11,292
Window 8, + 3 fits:: 12,306 10,996
One-utterance window, no gate 11,419 10,272

Onboarding is over the soft budget with the rate group alone, even at a one-utterance window, so planRequest always drops the gate there (after all_phrasing, before window_to_8): the rate item keeps its title and shows "Listening" while any rate-type concern is open. In customer success the three fits:: push a 12-utterance request over soft, so the gate is dropped too; the episode's fits:: alone would stay. The unit tests of the rate item (tests/unit/engine-fit.test.ts (7), engine-rate.test.ts) put the gate's questions to the stub without the budget, as engine-rate already did for the rate group.

For comparison, the red-pen rounds alone (the committed sources before this restructure, fixtures regenerated) put the onboarding worst case over the soft budget: tests/unit/policy-build.test.ts and tests/unit/engine-groups.test.ts fail their ≤ 11,000 checks in that state. Moving the approved lines into situations removes the long phrasing:: Choices from real bundles and brings the budget back.

3.5 What the rep sees

State Current item shows
No candidate, or none fits Item title and "Listening"
A situation fits (and, with two or more, the Choice agrees) Item title, the situation's headline in quotes, its points ("Worth saying"), ticks as they are covered
It fits, but every point waits for an unknown slot Item title, headline, "Listening"
Rate item, situation fits, qualify_first "Rate pressure", headline, hint, "Ask first" block, then the points

3.6 Live probe of the questions (COPILOT-095, 2026-09-26)

scripts/probe-questions.mjs --questions generates fits::<id> and situation::<move> from the draft playbook like covered:: (the situation:: Choice leaves out the rate helpers). Stevan's case, customer success, after the rep's "You should be able to see it on the dashboard now.":

$ node scripts/probe-questions.mjs --scenario customer_success --context rep:"You should be able to see it on the dashboard now." --latest client:"The payment to my supplier still hasn't arrived. Where is it?" --questions situation::agree_next_step,fits::choosing_a_delivery_date,fits::ready_to_book_after_a_fix
scenario: customer_success
planned: 3 ["situation::agree_next_step","fits::choosing_a_delivery_date","fits::ready_to_book_after_a_fix"]
estimate: 1418
model: jev-1.13.0
  situation::agree_next_step: {"type":"choice","choice":"none","probabilities":{"none":1,"choosing_a_delivery_date":0,"ready_to_book_after_a_fix":0},"confidence":0.99}
  fits::choosing_a_delivery_date: {"type":"noul","noul":0.03}
  fits::ready_to_book_after_a_fix: {"type":"noul","noul":0.03}
usage: {"input_tokens":1255,"output_tokens":100}
usage.input_tokens: 1255 (estimate 1418)

The same with --latest client:"Delivery date. Does it make a difference?":

scenario: customer_success
planned: 3 ["situation::agree_next_step","fits::choosing_a_delivery_date","fits::ready_to_book_after_a_fix"]
estimate: 1406
model: jev-1.13.0
  situation::agree_next_step: {"type":"choice","choice":"choosing_a_delivery_date","probabilities":{"none":0.02,"choosing_a_delivery_date":0.98,"ready_to_book_after_a_fix":0},"confidence":0.97}
  fits::choosing_a_delivery_date: {"type":"noul","noul":0.87}
  fits::ready_to_book_after_a_fix: {"type":"noul","noul":0.03}
usage: {"input_tokens":1245,"output_tokens":105}
usage.input_tokens: 1245 (estimate 1406)

Before the repair in §7 point 9, the second probe gave fits::choosing_a_delivery_date 0.33, 0.35 and 0.33 over three runs, with the Choice picking choosing_a_delivery_date at confidence 0.36 to 0.42: the old what asked for "booking a transfer on the platform during the call", which the turns never show. The first probe was unchanged by the repair (0.02 to 0.03 and none at 0.99, before and after).

3.7 In the engine (COPILOT-095b)

3.8 Slot requirements (COPILOT-097)

4. Worked examples

Each example gives the client turn, the questions the gate asks, the answers it needs and what the rep sees. They became the L1 cases in §4.6 (COPILOT-096).

4.1 Customer success: the delivery date (MUST-NOT)

Current item: agree_next_step (template transfer_issue). Candidates: ready_to_book_after_a_fix, choosing_a_delivery_date.

Latest client turn Questions Required answers Rep sees
"The payment to my supplier still hasn't arrived. Where is it?" situation::agree_next_step, fits::ready_to_book_after_a_fix, fits::choosing_a_delivery_date Choice none; both fits < 0.60 "Agree the next step", "Listening". Never agree_next_step.h2. (If the turn opens a settlement_delay concern, the plan inserts a concern item whose move, give_status_with_specifics, has where_is_my_money, which fits there.)
"I can't log in, it keeps asking me for a code." same none; both < 0.60 (the block is not fixed yet) "Listening". Neither line.
"Great, that's all sorted, thanks for your help." same none; both < 0.60 "Listening". Not the delivery-date line, and not "You should be able to book the trade now." either: nothing was blocking a booking.
"Delivery date. Does it make a difference?" (while booking on the platform) same Choice choosing_a_delivery_date ≥ 0.40; fits::choosing_a_delivery_date ≥ 0.60 Headline "Which delivery date do I pick?", point agree_next_step.h2
"But what I'm gonna do now is just log on and then try to reset up the two factor authentication and do the trade that I wanted to do." (after the rep fixed the two-factor problem) same Choice ready_to_book_after_a_fix; its fits ≥ 0.60; fits::choosing_a_delivery_date < 0.60 Headline "I'll log on and do the trade now.", point agree_next_step.h1

4.2 Customer success: where is my money (slots)

Current item: give_status_with_specifics. One candidate, where_is_my_money, so no Choice.

Latest client turn Required Rep sees
"So are they gonna have the money today?", pair known (GBP to EUR) none: where_is_my_money is not a candidate while trade_status_known has no source (COPILOT-097, §3.8) "Give the status with specifics", "Listening". Each of the three lines asserts a payment status the call has not established. Once a source exists: .2, .h2 and .h1, with {sell_currency} = GBP and {buy_currency} = EUR
Same, pair not known same "Listening". Once a source exists: .2 only (.h1 and .h2 wait for currency_pair)
"Which delivery date should I put for the next one?" fits::where_is_my_money < 0.60 "Listening" (a new booking's date is not a payment in flight)

4.3 Onboarding: the funding account

Current item: confirm_funding_account. Candidates: funding_owner_unclear, and bank_abroad_not_named unless the residency slot is GB.

Latest client turn Required Rep sees
"It'll well, I've got a few options. I can put it in my daughter's account and send it over." Choice funding_owner_unclear; its fits ≥ 0.60 "Whose account will the money come from, yours or someone else's?"
"Well, it's so so this will be transfers that I I will be doing in the future. But yeah. So from Saudi Real into Sterling. Yeah." (residency SA known) Choice bank_abroad_not_named; its fits ≥ 0.60 confirm_funding_account.h1 with {country} filled from the residency value SA (COPILOT-097 renders the country's name, not the chip label "Lives in Saudi Arabia")
Same turn, residency not known yet same "Listening": confirm_funding_account.h1 waits for residency
"Is my money safe with you, though?" Choice none; both fits < 0.60 "Listening" (if a safety concern opens, its concern item's move is explain_who_holds_the_money, where is_my_money_safe fits)

4.4 Onboarding: who holds the money (partner gating)

Current item: explain_who_holds_the_money. Candidates: is_my_money_safe only; where_do_i_send_funds needs settlement_partner_known, which nothing sets yet.

Latest client turn Required Rep sees
"you know, ready for a pretrade, is that FCA FCA protected?" fits::is_my_money_safe ≥ 0.60 "The funds are held in a segregated account, …"
"So, I book it online, and it should come out with the proper rate and also send me the instructions on how, how to actually transfer the funds. Is that correct?" fits::is_my_money_safe < 0.60 "Listening". explain_who_holds_the_money.h2 ("… with {settlement_bank} in {settlement_city}") is never shown until the partner is a known fact, per Stevan's 2026-09-25 ruling.

4.5 Onboarding: rate pressure

Rate item after "Company X gives me a better rate." (rate episode competitor_named). Candidates for fits::: competitor_named and the next two by probability.

Latest client turn Required Rep sees
"Company X gives me a better rate." rate_objection_type = competitor_named ≥ 0.40; fits::competitor_named ≥ 0.60 "Rate pressure", headline, hint, "Ask first" (qualify_the_quote.1 to .4), then competitor_named.1 and .2
"Sorry, can I just ask how long the transfer takes once I book?" (same episode, concern still open) fits::competitor_named < 0.60 "Rate pressure", "Listening" until the client comes back to the rate

4.6 L1 cases (COPILOT-096)

eval/labelled/like-for-like.json holds the like-for-like L1 cases for both scenarios. Each case names its own scenario, as in rate.json. The file is loaded by loadLikeForLikeCases and included in loadAllCases (scripts/lib/eval-cases.ts). It has 74 cases and 174 checks:

validateCases accepts fits::<situation> as a noul and situation::<move> as a Choice over the move's candidates plus none. Both are generated from the scenario's playbook in the same way the engine asks them (gateQuestion), and anything else still fails as "not in the bank". probe-questions.mjs --cases generates the same questions for each case (assembleQuestions(…, fits)).

Live probe, 2026-09-26. Two runs gave the same totals, and the same checks failed in both. Second run:

$ node scripts/probe-questions.mjs --cases eval/labelled/like-for-like.json
…
PASS lfl_cs_choosing_a_delivery_date (customer_success)
    pass fits::choosing_a_delivery_date >= 0.6: 0.96
    pass situation::agree_next_step in [choosing_a_delivery_date]: choosing_a_delivery_date (1)
    pass fits::ready_to_book_after_a_fix < 0.6: 0.02
…
PASS lfl_cs_must_not_delivery_date (customer_success)
    pass fits::choosing_a_delivery_date < 0.6: 0.03
    pass fits::ready_to_book_after_a_fix < 0.6: 0.02
    pass situation::agree_next_step in [none]: none (0.99)
model: jev-1.13.0
cases: 67/74 passed (90.5%); checks: 165/174 (94.8%)

Every failing check is listed below with its proposed repair. Each repair is a policy edit for Stevan's red pen: none has been applied, and no threshold was lowered. The scores are from run 2, with run 1 in brackets.

Case Failing check Got Proposed repair
lfl_ob_where_do_i_send_funds fits::where_do_i_send_funds >= 0.6; situation::explain_who_holds_the_money in [where_do_i_send_funds] 0.24 (0.2); none (0.4 / 0.49) Drop "and the activating partner for this client is known" from what. Nothing in a client turn can show it, and applies.when_fact: settlement_partner_known already gates it (§7 point 2).
lfl_ob_none_close_the_document_gap fits::selfie_missing_or_rejected < 0.6 0.72 (0.7) The clause "or the client says their selfie or ID already went through" catches "I've uploaded everything and it all went through". Limit it to a selfie or ID the rep has just said was rejected, and add "a client who says all their documents went through, with no document named as missing" to not_for.
lfl_ob_none_agree_next_step fits::activation_next_steps < 0.6 0.71 (0.71) Add "a question about the transfer itself (how long it takes, the rate) asked at the end of the call" to not_for. The what clause "asks … when they can transfer" reads close to "how long do transfers take".
lfl_cs_invoice_needed_for_payment fits::invoice_needed_for_payment >= 0.6 0.37 (0.4) what requires the client to "ask what the problem is or what is needed". The client example reports what they have done ("I messaged the electrician … could he provide it"). Add "or says they have asked the payee for it".
lfl_cs_upcoming_payments_unknown situation::review_upcoming_exposure in [upcoming_payments_unknown] none (0.7 / 0.79); fit passes at ≥ 0.6 The Choice reads "nothing happening" on a thank-you after a fix. Rewrite what from the rep's opportunity rather than the client's silence: "The client's issue is dealt with and the client is wrapping up or thanking the rep, and has not said what payments abroad they have coming up."
lfl_cs_payment_mentioned_no_amount fits::payment_mentioned_no_amount >= 0.6; the Choice 0.04 (0.05); none (0.75 / 0.77) The harvest example "I just didn't wanna screw things up the fur I'm gonna be doing it probably a little more frequently over the next few months." is too weak for this situation. Replace it with a clearer one, or broaden what to "says they will be making transfers (more often, soon, over the coming months)".
lfl_cs_several_payments_at_once fits::several_payments_at_once >= 0.6 0.59 (0.55) The example ("eight supplier invoices to pay this week") does not say they book them "at the same time". Change what to "pays several different people or suppliers in the same week or run".

5. Before the gate (history)

Superseded by COPILOT-095b (2026-09-26): pointsOf(move, shown, p) now shows only the situation the current item's gate shows, and there is no fallback to the first situation or to flat lines for a move with situations (§3.7). What follows describes the tree between the restructure and the gate.

The engine in the tree today reads the restructured policy through pointsOf (src/engine/situation.ts): the open rate episode's situation, else the move's approved flat lines, else the first situation with an approved point. After the restructure no move has an approved flat line, so each move shows its first situation, with all its points. The situations are ordered so that the first is the most general:

Nothing is published from this state: a real bundle is still refused by the loader for its draft lines and [VERIFY markers, exactly as before the restructure.

6. Tests, fixtures and the red-pen sheet

Unit suite in the worktree (npx vitest run tests/unit): 7 failures before the restructure, all from red-pen rounds 1 to 6 changing policy/src without the tests (the four stale committed fixtures, the phrasing:: id list, the fixture line counts, the REDPEN counts). After it, 10. The three new failures are tests that pin the old structure; none is a defect, and COPILOT-095 carries the fixes:

Test Why it fails now Fix (095)
tests/unit/policy-drafts.test.ts "every move, line and situation point is well formed" A point's text_id must be <situation id>.<n>; moved lines keep their move's id (open_with_agenda.h1 under call_just_answered) A point's id is <situation id>.<n> or, for a line moved in, <its move id>.<n> / .h<n>
tests/unit/policy-text.test.ts "refuses a duplicate text_id when the scheme is kept" It duplicates the first move, which now has a situation, so the duplicate-situation check fires first Duplicate a move without situations, or expect either refusal
tests/unit/policy-retired.test.ts "a retired situation point is dropped from the bundle" It retires the first point of the playbook's first situation, now call_just_answered's only point, which is its core Retire a non-core point of a situation with two or more points (qualify_the_quote.1)

The committed fixtures (tests/fixtures/policy/*.fixture.json) were deliberately not regenerated. The stub answers (tests/fixtures/answers/*.stub.json) pin the fixture policy_hash, and regenerating only the policy fixtures breaks engine-step, engine-plan and engine-fact-values (24 failures in a sandbox copy). With both regenerated (sandbox), the four stale-fixture tests and the budget pass, and eight engine tests that pin the old flat lines of the fixture fail instead (engine-card lines ×3, engine-budget phrasing_beyond_top4, engine-covered ×3, engine-situations situationsOf). COPILOT-095 regenerates both and rewrites those tests for the gate.

policy/REDPEN.md lists every situation under its move: headline, hint, what, not_for, applies in words, every client example ("Client example (source): text"), the core line, and the points with status and their placeholders (slot requirements and rep fills), since COPILOT-095. §8 below keeps the provenance of each client example.

COPILOT-095b rewrote them, and every other engine test the gate changes, against the gate: the card and plan tests that expected the open rate episode's situation (or a move's first situation) without a fit now name the situation and the fits:: answer that shows it (engine-card, engine-situations, engine-plan, engine-covered, engine-rate, policy-retired); the phrasing:: tests run on a copy of a move without situations. With the gate the exemplar replays show few points (the stub fits a situation only on the turns STUB_FITS names), so engine-covered's count of replay requests carrying a covered:: noul went from more than 50 to at least one. Unit suite after 095b: 842 passed, 0 failed.

COPILOT-095 regenerated both fixture sets and made those fixes. The unit suite then fails exactly the eight engine tests that pin the old flat lines (engine-card "pickCard: lines" ×3, engine-budget "phrasing_beyond_top4 keeps the card move…", engine-covered ×3, engine-situations "a move without the chosen situation gets none…"); COPILOT-095b rewrites them against the gate. The other phrasing:: budget tests run on a copy of the fixture without situations, because a move with situations no longer gets a phrasing:: Choice.

npm run typecheck exits 0 (the restructure touches JSON only).

7. Open points for Stevan

  1. Headlines and hints of the 35 new situations are drafts: red-pen them like move headlines (situation.<id>.headline, situation.<id>.hint).

  2. where_do_i_send_funds (explain_who_holds_the_money.h2) can never show: settlement_partner_known is not a signal and settlement_partner is not a fact slot. COPILOT-095 adds the fact; its source (the rep's screen or the call) is your call.

  3. reengage_dormant_with_a_reason.1 ("You last transferred in {month}; …") waits for last_transfer_month, which nothing supplies in M1; its alternate .3 shows instead. Should {month} be a rep fill?

  4. putting_off_booking (offer_forward_for_known_future_payment.h1, "Do you still want to lock the rate in today?") was said to a client spreading bookings over the next four nights, which the move's own not_for excludes ("Payments due within days"). Keep it under the forward move, or move it?

  5. open_with_agenda.h1 writes its placeholder {currency pair} with a space; code placeholders are {snake_case}. It is a rep fill today; normalise it if code should fill it from the pair chip.

  6. give_status_with_specifics: the three status lines answer one client question and differ by the trade status only the rep sees. Each asserts a status (waiting for funds, received, gone) that no call fact establishes, so since COPILOT-097 where_is_my_money is gated on trade_status_known, which nothing sets: none of the three is ever on screen in M1, and the item shows its title and "Listening". Lifting it is your ruling: a source for the trade status, or alternates the rep chooses from the screen.

  7. set_up_recurring_or_batch.4 was deleted by COPILOT-095 (it existed only for the build).

  8. Rate situations: their what is now literal; the old framing is in note. The bank_matched headline was 9 words, and COPILOT-095's builder refuses more than 8, so it now reads "I spoke to my bank; they matched you." (draft; red-pen it). The PDF's client line, "I spoke to my bank and they've matched you.", stays verbatim as its client example.

  9. choosing_a_delivery_date's what was sharpened by COPILOT-095's live probe (§3.6): it required "booking a transfer on the platform during the call", which a client turn cannot show, and scored 0.33 on "Delivery date. Does it make a difference?". It now reads "The client asks about the delivery date of a transfer they are booking or about to book: which date to choose, whether the date makes a difference, why a date is not available, or whether it can go today." Red-pen it; the MUST-NOT case still scores 0.03.

  10. Budget: the gate and the rate group do not fit together (COPILOT-095b, §3.4). With a rate-type concern open, onboarding's request is over the 11,000 soft budget before any gate question, so the gate is always dropped and the rate item shows "Rate pressure" and "Listening" for the whole episode; in customer success the three fits:: of the rule push it over. Options, all a policy or spec change rather than a code default: ask only the episode's fits:: on the rate item (the decision rule reads no other; −475 tokens), move the gate's drop after window_to_8, trim the rate group (the five do-not nouls are rep-turn only), or raise the soft budget toward the 12,000 hard cap. Until one is chosen, rate points reach the screen only in customer success on short windows.

  11. check_serviceability_first.h1 cannot reach the screen on its item (COPILOT-097). "Have you been working in {country} for a while?" needs the residency slot, and the onboarding plan item check_serviceability_first has done_when: [{slot: 'residency'}], so the item is done on the same decision the residency becomes known and the plan moves on. The engine fills the point correctly when the item is current (tests/unit/engine-slots.test.ts), and the card leaves template points off. Options: keep the item open until the serviceability question is asked (a must_say in done_when), move the line to an item that is still open once residency is known, or accept that it never shows. confirm_funding_account.h1 is not affected.

8. Situations by scenario (red-pen copy)

Provenance: harvest <call:utt> prev|next is the client turn's place in data/analysis/harvest/turns.jsonl (the prev or next client turn of that candidate rep turn): "right before the line" = the client turn immediately before a rep turn of the line's cluster; "same call, earlier" = an earlier client turn of that call (the immediate one was a backchannel); "other call" = a matching client turn from another harvested call, found by keyword.

Onboarding

open_with_agenda (Set the agenda)

Flat lines left: open_with_agenda.1 (draft), open_with_agenda.2 (draft), open_with_agenda.3 (draft).

call_just_answered: "Hello? Who's speaking?"

check_serviceability_first (Check we can serve them)

Flat lines left: check_serviceability_first.1 (draft), check_serviceability_first.2 (draft), check_serviceability_first.3 (draft).

works_abroad: "I'm working abroad at the moment."

uk_address_unclear: "I don't live in the UK anymore."

ask_about_the_transfer (Ask an open question about the transfer)

Flat lines left: ask_about_the_transfer.1 (draft), ask_about_the_transfer.2 (draft), ask_about_the_transfer.3 (draft).

amount_not_said: "I'd like to send some money over."

first_transfer_date_not_said: "I know what I'm sending, not when."

pin_down_timing_and_amount (Pin down amount and date)

Flat lines left: pin_down_timing_and_amount.1 (draft), pin_down_timing_and_amount.2 (draft), pin_down_timing_and_amount.3 (draft).

plans_described_no_amount: "I'll be sending money over the coming months."

confirm_funding_account (Confirm the funding account)

Flat lines left: confirm_funding_account.1 (draft), confirm_funding_account.2 (draft), confirm_funding_account.3 (draft).

funding_owner_unclear: "It might come from my husband's account."

bank_abroad_not_named: "I'll be sending it from my account abroad."

close_the_document_gap (Name the missing document)

Flat lines left: close_the_document_gap.1 (draft), close_the_document_gap.2 (draft), close_the_document_gap.3 (draft).

documents_outstanding_next_step: "So what's the next step with the documents?"

selfie_missing_or_rejected: "I already sent you the selfie."

explain_who_holds_the_money (Explain who holds the money)

Flat lines left: explain_who_holds_the_money.1 (draft), explain_who_holds_the_money.2 (draft), explain_who_holds_the_money.3 (draft).

is_my_money_safe: "Is my money protected with you?"

where_do_i_send_funds: "Where do I actually send the money?"

show_the_rate_against_mid_market (Compare the rate against mid-market)

Flat lines left: none.

mid_market_comparison: "Why am I getting less than Google says?"

explain_settlement_and_cutoff (Explain when the money arrives)

Flat lines left: explain_settlement_and_cutoff.2 (draft), explain_settlement_and_cutoff.3 (draft).

when_will_it_arrive: "How long does it take to arrive?"

explain_booking_is_binding (Explain that a booking is binding)

Flat lines left: explain_booking_is_binding.1 (draft), explain_booking_is_binding.2 (draft), explain_booking_is_binding.3 (draft).

learning_to_quote_and_book: "So I get a quote, then book?"

handle_rate_objection (Handle a rate objection)

Flat lines left: none.

qualify_the_quote: "Qualify the quote before you respond"

no_quote_in_hand: "Can you do better than that?"

competitor_named: "Company X gives me a better rate."

concession_terms: "If you do move on price"

handle_alternative_provider (Address the bank, Wise or Revolut comparison)

Flat lines left: handle_alternative_provider.2 (draft), handle_alternative_provider.3 (draft).

serial_shopper: "I'll just check what the others are doing."

bank_matched: "I spoke to my bank; they matched you."

offer_alert_instead_of_waiting (Offer a bookable rate alert)

Flat lines left: offer_alert_instead_of_waiting.2 (draft).

waiting_for_a_better_rate: "I'll hold off until the rate improves."

waiting_but_has_a_date: "I'd wait, but I have a deadline."

agree_next_step (Agree the next step)

Flat lines left: agree_next_step.1 (draft), agree_next_step.2 (draft), agree_next_step.3 (draft).

activation_next_steps: "So what happens now?"

Customer success

resolve_the_issue_first (Deal with the live issue before anything else)

Flat lines left: resolve_the_issue_first.1 (draft), resolve_the_issue_first.2 (draft), resolve_the_issue_first.3 (draft).

cancel_payment_at_own_bank: "My bank won't let me change it."

give_status_with_specifics (State the status with specifics)

Flat lines left: give_status_with_specifics.1 (draft), give_status_with_specifics.3 (draft).

where_is_my_money: "Will they have the money today?"

explain_document_request (Explain why a document was requested)

Flat lines left: explain_document_request.1 (draft), explain_document_request.2 (draft), explain_document_request.3 (draft).

which_statement_needed: "What exactly do you need, a bank statement?"

invoice_needed_for_payment: "What's the problem with my payment?"

review_upcoming_exposure (Ask about upcoming payments)

Flat lines left: review_upcoming_exposure.2 (draft), review_upcoming_exposure.3 (draft).

upcoming_payments_unknown: "That's all sorted now, thanks."

payment_mentioned_no_amount: "I'll be doing more of these soon."

offer_forward_for_known_future_payment (Offer a forward for a known future payment)

Also shows (through situation_refs): concession_terms.

Flat lines left: offer_forward_for_known_future_payment.2 (draft), offer_forward_for_known_future_payment.3 (draft).

future_payment_rate_worry: "What if the rate moves before I pay?"

putting_off_booking: "I'll check it again tomorrow."

offer_alert_or_market_order (Offer a rate alert or market order)

Flat lines left: offer_alert_or_market_order.2 (draft).

waiting_for_target_rate: "I'm waiting for the rate to go up."

set_up_recurring_or_batch (Set up recurring or batch payments)

Flat lines left: none (set_up_recurring_or_batch.4 was deleted by COPILOT-095).

same_payments_every_month: "It's the same payment every month."

several_payments_at_once: "I pay several suppliers at once."

add_user_access (Add a colleague or approval step)

Flat lines left: add_user_access.3 (draft).

someone_else_books_payments: "My bookkeeper usually does the payments."

wants_a_second_approver: "Can someone else check payments before they go?"

handle_rate_complaint (Handle a rate complaint)

Flat lines left: none.

handle_service_complaint (Acknowledge a service failure and fix the process)

Flat lines left: handle_service_complaint.1 (draft), handle_service_complaint.2 (draft), handle_service_complaint.3 (draft).

calling_back_about_a_complaint: "Is this about my email?"

reengage_dormant_with_a_reason (Re-engage with a specific reason)

Flat lines left: reengage_dormant_with_a_reason.2 (draft).

not_traded_for_a_while: "I haven't used it in a while."

accept_no_need_gracefully (Accept there is no current need)

Flat lines left: accept_no_need_gracefully.2 (draft).

no_transfers_planned: "Nothing planned at the moment."

agree_next_step (Agree the next step)

Flat lines left: agree_next_step.1 (draft), agree_next_step.2 (draft), agree_next_step.3 (draft).

ready_to_book_after_a_fix: "I'll log on and do the trade now."

choosing_a_delivery_date: "Which delivery date do I pick?"

14,066 words · Internal working documents. Do not share outside CurrencyTransfer.