Queries · q271 · Head of Merchant Success · week 2, night 3
“Do shoppers who stop engaging with campaigns go quiet sooner, and does disengagement show up before they lapse?”
Fictional people at a fictional company, each played by a language-model agent. Who they are and what they are told
Week 2; shared with Chloé Marchetti, Head of Growth Marketing and Daniel Achterberg, VP Customer Experience
Churn has become a board topic, for merchants and for their shoppers. A cross-functional task force wants to understand which experiences precede leaving, and which early signals would let us act before it happens.
The manager, at the start of week 2
Subject: Next week's priorities — QBR follow-ups + the churn task force
Hi Arjun,
Really useful memo — thank you. A few things jumped out that I want to understand better, plus a new cross-functional ask that's going to need a slot or two this week.
What surprised me / needs explaining first:
My questions for this week:
New context — the churn task force: Churn is now a board topic, both merchant churn and end-shopper churn, and I'm leading this jointly with Chloé Marchetti and Daniel Achterberg. The deliverable the three of us owe the board is a shortlist of early-warning signals — things we can observe weeks or months before a merchant (or a merchant's shoppers) leaves — and a recommendation on which of those signals is reliable enough to act on. It feeds directly into two decisions: whether Merchant Success should get a proactive outreach trigger for at-risk accounts, and whether Growth Marketing and CX get an equivalent retention trigger for end-shoppers. So anything you can tell me this week about what preceded past departures — on the merchant side especially, since that's my piece — is directly useful input, even if it's partial. I'll compare notes with Chloé's and Daniel's analysts before we draft anything for the board.
Given the slot limits, prioritize in this order: Castell explanation, then the task-force churn pattern, then support/CSAT if a slot remains.
Thanks, Samuel
Subject: CX signals for the churn task force
Arjun,
A few things I'd want the task force to look at from the shopper/support side:
If we can pin down even one or two of these as reliable early signals, it changes what Samuel's team and I should be monitoring week to week rather than finding out after the fact.
Daniel
Subject: Churn task force — what I'd like us to look at from Growth
Hi Arjun,
Glad this is getting cross-functional attention. From the marketing side, here's what I think matters:
Happy to jump on a call if useful — mainly want to make sure whatever "early warning" signals come out of this include the marketing-touch side, not just support or payment signals.
Best, Chloé
The analyst · week 2, night 3, request 2 of 3
Last night's result: only 9 merchants are formally 'churned', and they are all tiny (1-11 orders in their final quarter, many with zero prior-period history) - these look like accounts that never gained traction rather than established businesses declining, so there isn't much of a "warning sign" trend to extract from them, and ticket/refund signals are mostly flat at zero. To give the task force something more useful, tonight I broaden the net to substantial merchants who have gone quiet without being flagged 'churned' (silent churn), and start on the shopper-level asks from Chloe and Daniel: does marketing disengagement or worse support precede a shopper going quiet. These are both new, independent questions at a different grain, so each gets its own report.
The engineer · 1 dry run on the empty schema, then the submission
WITH user_orders AS ( SELECT tenant_id, user_id, MAX(placed_at) AS last_order_at, COUNT(*) AS order_count FROM orders WHERE status <> 'cancelled' GROUP BY tenant_id, user_id ), classified AS ( SELECT tenant_id, user_id, CASE WHEN order_count >= 3 AND last_order_at::date <= DATE '2025-12-31' - INTERVAL '180 days' AND last_order_at::date >= DATE '2025-12-31' - INTERVAL '365 days' THEN 'lapsed' WHEN order_count >= 3 AND last_order_at::date > DATE '2025-12-31' - INTERVAL '90 days' AND last_order_at::date <= DATE '2025-12-31' THEN 'active' ELSE NULL END AS shopper_group, CASE WHEN order_count >= 3 AND last_order_at::date <= DATE '2025-12-31' - INTERVAL '180 days' AND last_order_at::date >= DATE '2025-12-31' - INTERVAL '365 days' THEN last_order_at::date WHEN order_count >= 3 AND last_order_at::date > DATE '2025-12-31' - INTERVAL '90 days' AND last_order_at::date <= DATE '2025-12-31' THEN DATE '2025-12-31' ELSE NULL END AS reference_date FROM user_orders ), eligible AS ( SELECT tenant_id, user_id, shopper_group, reference_date FROM classified WHERE shopper_group IS NOT NULL ), messages AS ( SELECT e.shopper_group, e.tenant_id, e.user_id, CASE WHEN (e.reference_date - cm.sent_at::date) BETWEEN 0 AND 90 THEN '0-90 days before reference' WHEN (e.reference_date - cm.sent_at::date) BETWEEN 91 AND 180 THEN '91-180 days before reference' ELSE NULL END AS window_label, cm.opened_at, cm.clicked_at FROM eligible e JOIN campaign_messages cm ON cm.tenant_id = e.tenant_id AND cm.user_id = e.user_id AND cm.sent_at IS NOT NULL ) SELECT shopper_group, window_label AS window_before_reference, COUNT(DISTINCT tenant_id || '-' || user_id) AS shopper_count, COUNT(*) AS messages_sent, COUNT(opened_at) AS messages_opened, ROUND(100.0 * COUNT(opened_at) / NULLIF(COUNT(*), 0), 1) AS open_rate_pct, COUNT(clicked_at) AS messages_clicked, ROUND(100.0 * COUNT(clicked_at) / NULLIF(COUNT(*), 0), 1) AS click_rate_pct FROM messages WHERE window_label IS NOT NULL GROUP BY shopper_group, window_label ORDER BY shopper_group, window_label
“Query compiles and matches the requested grain and columns. Submitting.”
“Submitted. Summary: the query classifies shoppers into 'lapsed' and 'active' groups based on their last non-cancelled order date and lifetime order count (≥3), sets each shopper's reference date accordingly, buckets their campaign messages into the two 90-day windows before that reference date, and reports shopper counts plus sent/opened/clicked volumes and rates per group/window — yielding the expected 4 rows.”
As Arjun Patel saw it, from the small dataset the agents worked on
shopper_group | window_before_reference | shopper_count | messages_sent | messages_opened | open_rate_pct | messages_clicked | click_rate_pct --------------+------------------------------+---------------+---------------+-----------------+---------------+------------------+--------------- active | 0-90 days before reference | 704 | 5896 | 2149 | 36.4 | 274 | 4.6 active | 91-180 days before reference | 694 | 5958 | 2225 | 37.3 | 251 | 4.2 lapsed | 0-90 days before reference | 283 | 2592 | 933 | 36.0 | 144 | 5.6 lapsed | 91-180 days before reference | 264 | 2233 | 801 | 35.9 | 86 | 3.9 (4 rows)
Changed only where the benchmark needs the same result on every system
WITH user_orders AS ( SELECT tenant_id, user_id, MAX(placed_at) AS last_order_at, COUNT(*) AS order_count FROM orders WHERE status <> 'cancelled' GROUP BY tenant_id, user_id),classified AS ( SELECT tenant_id, user_id, CASE WHEN order_count >= 3 AND last_order_at::date <= DATE '2025-12-31' - INTERVAL '180 days' AND last_order_at::date >= DATE '2025-12-31' - INTERVAL '365 days' AND CAST(last_order_at AS date) <= DATE '2025-12-31' - INTERVAL '180 days' AND CAST(last_order_at AS date) >= DATE '2025-12-31' - INTERVAL '365 days' THEN 'lapsed' WHEN order_count >= 3 AND last_order_at::date > DATE '2025-12-31' - INTERVAL '90 days' AND last_order_at::date <= DATE '2025-12-31' AND CAST(last_order_at AS date) > DATE '2025-12-31' - INTERVAL '90 days' AND CAST(last_order_at AS date) <= DATE '2025-12-31' THEN 'active' ELSE NULL END AS shopper_group, CASE WHEN order_count >= 3 AND last_order_at::date <= DATE '2025-12-31' - INTERVAL '180 days' AND last_order_at::date >= DATE '2025-12-31' - INTERVAL '365 days' THEN last_order_at::date AND CAST(last_order_at AS date) <= DATE '2025-12-31' - INTERVAL '180 days' AND CAST(last_order_at AS date) >= DATE '2025-12-31' - INTERVAL '365 days' THEN CAST(last_order_at AS date) WHEN order_count >= 3 AND last_order_at::date > DATE '2025-12-31' - INTERVAL '90 days' AND last_order_at::date <= DATE '2025-12-31' AND CAST(last_order_at AS date) > DATE '2025-12-31' - INTERVAL '90 days' AND CAST(last_order_at AS date) <= DATE '2025-12-31' THEN DATE '2025-12-31' ELSE NULL END AS reference_date FROM user_orders),eligible AS ( SELECT tenant_id, user_id, shopper_group, reference_date FROM classified WHERE shopper_group IS NOT NULL),messages AS ( SELECT e.shopper_group, e.tenant_id, e.user_id, e.shopper_group AS shopper_group, e.tenant_id AS tenant_id, e.user_id AS user_id, CASE WHEN (e.reference_date - cm.sent_at::date) BETWEEN 0 AND 90 THEN '0-90 days before reference' WHEN (e.reference_date - cm.sent_at::date) BETWEEN 91 AND 180 THEN '91-180 days before reference' WHEN (e.reference_date - CAST(cm.sent_at AS date)) BETWEEN 0 AND 90 THEN '0-90 days before reference' WHEN (e.reference_date - CAST(cm.sent_at AS date)) BETWEEN 91 AND 180 THEN '91-180 days before reference' ELSE NULL END AS window_label, cm.opened_at, cm.clicked_at cm.opened_at AS opened_at, cm.clicked_at AS clicked_at FROM eligible e JOIN campaign_messages cm ON cm.tenant_id = e.tenant_id AND cm.user_id = e.user_id AND cm.sent_at IS NOT NULL)SELECT shopper_group, window_label AS window_before_reference, COUNT(DISTINCT tenant_id || '-' || user_id) AS shopper_count, COUNT(*) AS messages_sent, COUNT(opened_at) AS messages_opened, ROUND(100.0 * COUNT(opened_at) / NULLIF(COUNT(*), 0), 1) AS open_rate_pct, 100.0 * COUNT(opened_at) / NULLIF(COUNT(*), 0) AS open_rate_pct, COUNT(clicked_at) AS messages_clicked, ROUND(100.0 * COUNT(clicked_at) / NULLIF(COUNT(*), 0), 1) AS click_rate_pct 100.0 * COUNT(clicked_at) / NULLIF(COUNT(*), 0) AS click_rate_pctFROM messagesWHERE window_label IS NOT NULLGROUP BY shopper_group, window_labelORDER BY shopper_group, window_labelORDER BY shopper_group NULLS LAST, window_label NULLS LAST
WITH user_orders AS ( SELECT tenant_id, user_id, MAX(placed_at) AS last_order_at, COUNT(*) AS order_count FROM orders WHERE status <> 'cancelled' GROUP BY tenant_id, user_id ), classified AS ( SELECT tenant_id, user_id, CASE WHEN order_count >= 3 AND CAST(last_order_at AS date) <= DATE '2025-12-31' - INTERVAL '180 days' AND CAST(last_order_at AS date) >= DATE '2025-12-31' - INTERVAL '365 days' THEN 'lapsed' WHEN order_count >= 3 AND CAST(last_order_at AS date) > DATE '2025-12-31' - INTERVAL '90 days' AND CAST(last_order_at AS date) <= DATE '2025-12-31' THEN 'active' ELSE NULL END AS shopper_group, CASE WHEN order_count >= 3 AND CAST(last_order_at AS date) <= DATE '2025-12-31' - INTERVAL '180 days' AND CAST(last_order_at AS date) >= DATE '2025-12-31' - INTERVAL '365 days' THEN CAST(last_order_at AS date) WHEN order_count >= 3 AND CAST(last_order_at AS date) > DATE '2025-12-31' - INTERVAL '90 days' AND CAST(last_order_at AS date) <= DATE '2025-12-31' THEN DATE '2025-12-31' ELSE NULL END AS reference_date FROM user_orders ), eligible AS ( SELECT tenant_id, user_id, shopper_group, reference_date FROM classified WHERE shopper_group IS NOT NULL ), messages AS ( SELECT e.shopper_group AS shopper_group, e.tenant_id AS tenant_id, e.user_id AS user_id, CASE WHEN (e.reference_date - CAST(cm.sent_at AS date)) BETWEEN 0 AND 90 THEN '0-90 days before reference' WHEN (e.reference_date - CAST(cm.sent_at AS date)) BETWEEN 91 AND 180 THEN '91-180 days before reference' ELSE NULL END AS window_label, cm.opened_at AS opened_at, cm.clicked_at AS clicked_at FROM eligible e JOIN campaign_messages cm ON cm.tenant_id = e.tenant_id AND cm.user_id = e.user_id AND cm.sent_at IS NOT NULL ) SELECT shopper_group, window_label AS window_before_reference, COUNT(DISTINCT tenant_id || '-' || user_id) AS shopper_count, COUNT(*) AS messages_sent, COUNT(opened_at) AS messages_opened, 100.0 * COUNT(opened_at) / NULLIF(COUNT(*), 0) AS open_rate_pct, COUNT(clicked_at) AS messages_clicked, 100.0 * COUNT(clicked_at) / NULLIF(COUNT(*), 0) AS click_rate_pct FROM messages WHERE window_label IS NOT NULL GROUP BY shopper_group, window_label ORDER BY shopper_group NULLS LAST, window_label NULLS LAST
The agents' own records, as the role play wrote them; only the personal data of the person who ran it is redacted
Report request from Arjun Patel (analyst for the Head of Merchant Success): Title: Marketing engagement before shoppers go quiet: lapsed vs active shoppers Question: Do shoppers who stop engaging with campaigns go quiet sooner, and does disengagement show up before they lapse? Grain: one row per shopper group and time window before reference date Columns: - shopper group - window before reference - shopper count - messages sent - messages opened - open rate pct - messages clicked - click rate pct Details: Two groups, each needing at least 3 lifetime orders excluding cancelled: 'lapsed' = last order placed_at 180 to 365 days before 2025-12-31; 'active' = last order within 90 days of 2025-12-31. Reference date is the user's own last order date for lapsed, and 2025-12-31 for active. Two windows per group: '0-90 days before reference' and '91-180 days before reference'. From campaign_messages (sent_at, opened_at, clicked_at) count sent, opened, clicked per group/window; open rate pct is opened divided by sent; click rate pct is clicked divided by sent; shopper count is distinct users in that window. Order by shopper group, then window ascending. Expect 4 rows. Check your query with the warehouse's dry_run tool, then hand it in with submit.
dry_runWITH user_orders AS ( SELECT tenant_id, user_id, MAX(placed_at) AS last_order_at, COUNT(*) AS order_count FROM orders WHERE status <> 'cancelled' GROUP BY tenant_id, user_id ), classified AS ( SELECT tenant_id, user_id, CASE WHEN order_count >= 3 AND last_order_at::date <= DATE '2025-12-31' - INTERVAL '180 days' AND last_order_at::date >= DATE '2025-12-31' - INTERVAL '365 days' THEN 'lapsed' WHEN order_count >= 3 AND last_order_at::date > DATE '2025-12-31' - INTERVAL '90 days' AND last_order_at::date <= DATE '2025-12-31' THEN 'active' ELSE NULL END AS shopper_group, CASE WHEN order_count >= 3 AND last_order_at::date <= DATE '2025-12-31' - INTERVAL '180 days' AND last_order_at::date >= DATE '2025-12-31' - INTERVAL '365 days' THEN last_order_at::date WHEN order_count >= 3 AND last_order_at::date > DATE '2025-12-31' - INTERVAL '90 days' AND last_order_at::date <= DATE '2025-12-31' THEN DATE '2025-12-31' ELSE NULL END AS reference_date FROM user_orders ), eligible AS ( SELECT tenant_id, user_id, shopper_group, reference_date FROM classified WHERE shopper_group IS NOT NULL ), messages AS ( SELECT e.shopper_group, e.tenant_id, e.user_id, CASE WHEN (e.reference_date - cm.sent_at::date) BETWEEN 0 AND 90 THEN '0-90 days before reference' WHEN (e.reference_date - cm.sent_at::date) BETWEEN 91 AND 180 THEN '91-180 days before reference' ELSE NULL END AS window_label, cm.opened_at, cm.clicked_at FROM eligible e JOIN campaign_messages cm ON cm.tenant_id = e.tenant_id AND cm.user_id = e.user_id AND cm.sent_at IS NOT NULL ) SELECT shopper_group, window_label AS window_before_reference, COUNT(DISTINCT tenant_id || '-' || user_id) AS shopper_count, COUNT(*) AS messages_sent, COUNT(opened_at) AS messages_opened, ROUND(100.0 * COUNT(opened_at) / NULLIF(COUNT(*), 0), 1) AS open_rate_pct, COUNT(clicked_at) AS messages_clicked, ROUND(100.0 * COUNT(clicked_at) / NULLIF(COUNT(*), 0), 1) AS click_rate_pct FROM messages WHERE window_label IS NOT NULL GROUP BY shopper_group, window_label ORDER BY shopper_group, window_label
STATUS: COMPILES (dry run on the empty schema, no data read) COLUMNS: shopper_group, window_before_reference, shopper_count, messages_sent, messages_opened, open_rate_pct, messages_clicked, click_rate_pct
submitWITH user_orders AS ( SELECT tenant_id, user_id, MAX(placed_at) AS last_order_at, COUNT(*) AS order_count FROM orders WHERE status <> 'cancelled' GROUP BY tenant_id, user_id ), classified AS ( SELECT tenant_id, user_id, CASE WHEN order_count >= 3 AND last_order_at::date <= DATE '2025-12-31' - INTERVAL '180 days' AND last_order_at::date >= DATE '2025-12-31' - INTERVAL '365 days' THEN 'lapsed' WHEN order_count >= 3 AND last_order_at::date > DATE '2025-12-31' - INTERVAL '90 days' AND last_order_at::date <= DATE '2025-12-31' THEN 'active' ELSE NULL END AS shopper_group, CASE WHEN order_count >= 3 AND last_order_at::date <= DATE '2025-12-31' - INTERVAL '180 days' AND last_order_at::date >= DATE '2025-12-31' - INTERVAL '365 days' THEN last_order_at::date WHEN order_count >= 3 AND last_order_at::date > DATE '2025-12-31' - INTERVAL '90 days' AND last_order_at::date <= DATE '2025-12-31' THEN DATE '2025-12-31' ELSE NULL END AS reference_date FROM user_orders ), eligible AS ( SELECT tenant_id, user_id, shopper_group, reference_date FROM classified WHERE shopper_group IS NOT NULL ), messages AS ( SELECT e.shopper_group, e.tenant_id, e.user_id, CASE WHEN (e.reference_date - cm.sent_at::date) BETWEEN 0 AND 90 THEN '0-90 days before reference' WHEN (e.reference_date - cm.sent_at::date) BETWEEN 91 AND 180 THEN '91-180 days before reference' ELSE NULL END AS window_label, cm.opened_at, cm.clicked_at FROM eligible e JOIN campaign_messages cm ON cm.tenant_id = e.tenant_id AND cm.user_id = e.user_id AND cm.sent_at IS NOT NULL ) SELECT shopper_group, window_label AS window_before_reference, COUNT(DISTINCT tenant_id || '-' || user_id) AS shopper_count, COUNT(*) AS messages_sent, COUNT(opened_at) AS messages_opened, ROUND(100.0 * COUNT(opened_at) / NULLIF(COUNT(*), 0), 1) AS open_rate_pct, COUNT(clicked_at) AS messages_clicked, ROUND(100.0 * COUNT(clicked_at) / NULLIF(COUNT(*), 0), 1) AS click_rate_pct FROM messages WHERE window_label IS NOT NULL GROUP BY shopper_group, window_label ORDER BY shopper_group, window_label
Submitted to tonight's batch. Your work on this request is done.