Cross-sell
What it does
Per ADR-0010 D6, Ratiba's
cross-sell is slot-aware: after a primary booking is confirmed, the FSM
offers a complementary service only when an adjacent post-primary slot
exists within ±15 minutes of primary.end_at. The framing is "come for
your pedicure right after your manicure" — pre-primary slots are explicitly
not offered, because rearranging the customer's day is a materially
different proposition.
The offer path is bounded by five locked v1 mechanics:
- Top-1 candidate ranked by
relation.confidence × adjacency_tightness, whereadjacency_tightness = 1 − |gap_min| / tolerance_min. Tie-break isservice_a_idascending for deterministic behaviour. - Cascade cap of 1 per conversation (cascade depth = 1). Once the customer declines, no second offer fires in the same booking thread.
- Same-staff or staff-swap allowed: the candidate slot may be filled by
any staff member qualified for the candidate service, joined via
staff_services. - Returns empty when
cross_sell_dial == "never",upsell_dial == "never", nocomplementaryrelation row exists, no slot fits the adjacency window, the primary was cancelled, or the customer already declined this conversation. - Atomic ordered-pair reservation via Redis Lua (M11 T8): a single
EVALperformsSET NX EXon both keys atomically; on second-key failure the script rolls back the primary acquisition withDELbefore returning0. Default TTL 30 s.
The decline path calls release_pair(), returns the secondary slot to the
availability pool, and the primary booking continues with single-service
STK. The yes path advances to BUNDLE_CONFIRM, writes the secondary
appointment row, and emits a combined STK push using simple-sum pricing
(no v1 discount logic).
How it fits in the system
For the full FSM state graph that surrounds CONFIRM see
Conversation FSM.
Service relation graph
The cross-sell engine's candidate list is produced by the service relation
graph — a per-tenant graph of typed edges between services, stored in
<tenant>.service_relations:
| Column | Notes |
|---|---|
service_a_id | Source service (UUID FK to services) |
service_b_id | Target service (UUID FK to services) |
relation_type | One of complementary / alternative / sequential |
confidence | Float 0.0–1.0, LLM-inferred at catalog import |
direction | bidirectional or unidirectional |
Only complementary edges participate in cross-sell. alternative edges
are used by a future slot-fallback path; sequential edges are reserved for
bundled appointment workflows.
The relation graph is populated at catalog-import time by the relation inferrer. For how that inference works and how to re-run it on an existing catalog, see Catalog onboarding.
Confidence × adjacency ranking
find_cross_sell_options() applies a two-factor score to every eligible
(secondary_service, adjacent_slot) pair:
score = relation.confidence × adjacency_tightness
= relation.confidence × (1 − |gap_min| / tolerance_min)
Where gap_min is the absolute gap in minutes between primary.end_at and
secondary_slot.start_at, and tolerance_min is the adjacency window
(default 15 min). A zero-gap pair (back-to-back) scores the full confidence
weight; a 14-minute gap scores confidence × (1/15).
Dial gating
find_cross_sell_options() performs two dial checks before touching the
database:
cross_sell_dial == "never"is the explicit opt-out. Set it when the tenant doesn't want any post-booking offers.upsell_dial == "never"is a broader opt-out that silences both the upsell nudge during slot collection AND cross-sell post-confirm.cross_sell_dial == "related-only"(default for most verticals) limits candidates tocomplementaryrelation edges only — the same as v1.cross_sell_dial == "full-suggest"allows the engine to widen the candidate pool in a future version (reserved; behaves asrelated-onlyin v1).
For the full dial value table, see Personality dials.
How it flows
Adjacency window
The adjacency window is ±15 minutes around primary.end_at. Only the
post-primary half is used in v1 — slots starting before primary.end_at
are excluded unconditionally:
eligible range:
primary.end_at ≤ secondary.start_at ≤ primary.end_at + 15 min
Why post-primary only? Offering a pre-primary slot (e.g. "come earlier for an eyebrow wax before your cut") requires the customer to rearrange their arrival time — a materially different commitment than "stay 30 minutes longer". D6 locks this conservatively for v1; a "pre-primary with opt-in" flag is on the roadmap.
The window is not per-tenant configurable in v1. If a tenant's services are
typically scheduled with longer gaps, a future JSONB threshold override
on tenant_personality_config is the planned extension point.
Atomic ordered-pair reservation
Two slots must be locked simultaneously: the one already soft-reserved for
the primary service (extended TTL at CONFIRM) and the new secondary slot.
reserve_pair() achieves this with a single Redis EVAL call:
-- pseudo-code of the Lua script
local pk = KEYS[1] -- pair:reservation:<primary_slot_id>
local sk = KEYS[2] -- pair:reservation:<secondary_slot_id>
local ttl = ARGV[1]
if redis.call('SETNX', pk, ARGV[2]) == 1 then
if redis.call('SETNX', sk, ARGV[3]) == 1 then
redis.call('EXPIRE', pk, ttl)
redis.call('EXPIRE', sk, ttl)
return 1 -- both acquired
else
redis.call('DEL', pk) -- rollback
return 0 -- secondary was raced
end
else
return 0 -- primary was raced
end
Default TTL is 30 s. The entire CROSS_SELL_OFFER → CROSS_SELL_RESPONSE
window must complete within that TTL; on timeout the pair is released and
the primary booking continues. The 30 s ceiling is intentionally tight —
leaving a secondary slot locked while a customer reads a message for two
minutes would degrade availability for all other callers.
To observe active pair reservations: redis-cli KEYS "pair:reservation:*".
For the general per-thread SETNX mutex that guards booking turns, see
Conversation FSM.
Cascade cap
The cascade cap is enforced by cross_sell_declined=True on BookingState.
Once set, find_cross_sell_options() returns an empty list immediately —
before any database or Redis call. This prevents a customer who said "no"
from being re-offered a cross-sell if the FSM is somehow re-entered or if a
retry resets the conversation context.
The cross_sell_declined flag is per-thread (ULID thread lifetime), not
per-customer or per-session. A customer who declines today will be eligible
for a cross-sell offer on their next booking. Per-customer learning (e.g.
"this customer always declines") is a future personalisation concern outside
the v1 scope.
Where it lives in code
| Concern | File | Key entry point |
|---|---|---|
| Cross-sell decision | backend/app/services/cross_sell.py | find_cross_sell_options() |
| Adjacent slot search | backend/app/services/availability.py | find_adjacent_slot() |
| Atomic pair reservation | backend/app/services/reservations.py | reserve_pair() / release_pair() |
| CROSS_SELL_OFFER FSM node | backend/app/orchestrator/booking_graph.py | cross_sell_offer_node() |
| CROSS_SELL_RESPONSE FSM node | backend/app/orchestrator/booking_graph.py | cross_sell_response_node() |
| BUNDLE_CONFIRM FSM node | backend/app/orchestrator/booking_graph.py | bundle_confirm_node() |
| Relation graph | backend/app/persistence/catalog_imports.py | service_relations upsert (see catalog-onboarding) |
Decisions
- ADR-0010 — Tenant self-service customisation — D6 locks the slot-aware mechanic, the ±15-min window, the top-1 ranking formula, the cascade-cap of 1, the atomic-pair primitive, and the simple-sum pricing for v1.
Try this on local dev
-
Trigger the offer. Book a manicure end-to-end via WhatsApp (or
tests/e2e/cross_sell_e2e.pyfor headless). After CONFIRM, watch the logs forcross_sell.candidate_selectedfollowed by aCROSS_SELL_OFFERmessage rendering the pedicure adjacent slot. Verifypair_reservation_acquiredin Redis logs (KEYS pair:reservation:*inredis-cli). -
Walk the yes path. Reply
yes(orndio). ObserveCROSS_SELL_RESPONSEroute toaffirmative, thenBUNDLE_CONFIRMwrite a secondappointmentsrow. The STK push from the M-Pesa sandbox should show the combined primary + secondary amount; checkpayment_callbacksfor the merged bundle reference. -
Walk the decline path. Restart the scenario, reply
no(orhapana). Confirmrelease_pairfires (pair_reservation_releasedlog line), the secondary slot reappears infind_available_slots(), and the STK push fires for the single-service primary amount only. Thecross_sell_declined=Trueflag persists for the rest of the conversation, blocking any cascade re-offer. -
Inspect the relation graph. After onboarding a catalog, check which
complementaryedges were inferred:docker compose exec postgres psql -U ratiba -d ratiba \-c "SET search_path TO tenant_<slug>; \SELECT sa.name, sb.name, sr.confidence \FROM service_relations sr \JOIN services sa ON sa.id = sr.service_a_id \JOIN services sb ON sb.id = sr.service_b_id \WHERE sr.relation_type = 'complementary' \ORDER BY sr.confidence DESC;"Low-confidence edges (below ~0.4) rarely win the top-1 ranking unless the adjacency tightness is near 1.0.
-
Test dial gating. PATCH
cross_sell_dialtonever(see Personality dials), then re-run the booking.find_cross_sell_optionsshould return an empty list immediately, and the post-CONFIRM path should proceed straight toBOOKEDwithout emitting aCROSS_SELL_OFFER.
Related
- Conversation FSM — the booking state machine that hosts
CONFIRM,CROSS_SELL_OFFER,CROSS_SELL_RESPONSE, andBUNDLE_CONFIRMas first-class nodes. - Catalog onboarding — how
complementaryrelation edges are inferred by the LLM at import time and stored inservice_relations. - Personality dials — the
cross_sellandupselldial values that gate whether an offer fires. - Payments — the STK push orchestration that handles both single-service and combined-bundle totals.
- Observability — log events:
cross_sell.candidate_selected,pair_reservation_acquired,pair_reservation_released,cross_sell_declined. - Glossary — definitions for adjacency window, ordered-pair reservation, service relation graph, cross-sell, and cascade cap.