← All epicsBRD.md §9 · lines 7500–7945

Read-only per-epic slice. The canonical source of truth is BRD.md — stories are addressed by US-ID, not by this page's line numbers.

<!-- DERIVED — do not edit. Regenerate: `npx tsx tools/brd-epic-view/index.ts`. Source: BRD.md §9. -->

Epic 20 — Churn prevention flows (derived view)

Read-only per-epic slice of BRD.md §9, lines 7500–7945. The canonical source of truth is BRD.md — edit there, never here. The stable address for a story is its US-ID (US-20.x), not a line number. Regenerates on every dev → main sync via derive-state-on-main.

  • Stories (7): US-20.1, US-20.2, US-20.3, US-20.4, US-20.5, US-20.6, US-20.7
  • Generated: 2026-07-01T17:48:39.076Z · as-of commit: b083f095

Epic 20 — Churn prevention flows

<!-- traceability:start:BRD:Epic-20 -->

Prototype: Reason Capture · Interventions · Confirmation · A/B Variants · Intervention — Discount · Intervention — Cadence

<!-- traceability:end:BRD:Epic-20 -->

Value: Preventable cancellations are prevented, lifting LTV.

US-20.1: Cancel reason capture

<!-- traceability:start:US-20.1 -->

Prototype: Interventions · Cancel Flow

<!-- traceability:end:US-20.1 -->

Phase: MVP · Persona: Subscriber / Merchant Admin

As a Subscriber cancelling, I want to tell the merchant why, so that they can improve. As a Merchant Admin, I want cancel reasons logged, so that I can reduce preventable churn.

Acceptance criteria:

  • Given I click "Cancel," When the flow starts, Then I see a radio list of merchant-configured reasons + "Other."
  • Given I select a reason, When I continue, Then the reason is persisted with the cancel event.

UI states.

<!-- ui-states US-20.1 -->
surface: "Storefront subscriber portal (Svelte/Tailwind) — the cancel flow rendered by CancelSubscriptionButton.svelte, mounted on the active-subscription row in SubscriberPortalApp.svelte:439 (routes /account/subscriptions + /subscriptions) and on the active-only detail surface SubscriptionDetailView.svelte:99. NORTH-STAR: a reason-capture form (radio list of merchant-configured reasons + 'Other') is step 1 of the cancel flow, before any intervention is offered. TODAY the flow has NO reason step — clicking 'Cancel subscription' opens the intervention funnel directly (openFunnel, lines 37-42)."
idle:
  render: "Active subscription shows a 'Cancel subscription' button (lines 214-222, data-demo='portal-cancel-btn'). NORTH-STAR: activating it first renders the reason-capture form (radios + 'Other'); today it jumps straight to the intervention funnel (step='funnel')."
  primary_action: "NORTH-STAR: pick a reason then 'Continue' advances to interventions/confirm and persists the reason with the cancel event. TODAY no reason is captured; the funnel's 'Still want to cancel' (lines 201-211) then confirm then doCancel is the only path."
loading:
  trigger: "NORTH-STAR: persist the chosen cancel_reason with the cancel event. TODAY the only network step in this flow is doCancel which POSTs /api/v1/portal/subscriptions/{id}/cancel (lines 79-93); its body carries declined_intervention_types ONLY and never a cancel_reason (lines 83-85; signature api-client.ts:118)."
  render: "TODAY: while busy the 'Yes, cancel subscription' button shows 'Cancelling…' and is disabled (line 130); no double-submit."
error:
  surfaced_at: "Inline, scoped to this subscription via role='alert' beneath the active step — the confirm step (lines 120-122) and the funnel step (lines 163-165) both render the error this way; never a page-level banner. NORTH-STAR: the reason form surfaces a 'select a reason' validation error in the same inline pattern."
  recovery: "TODAY: on failure controls re-enable so the subscriber retries 'Yes, cancel subscription' or presses 'Go back' (lines 132-139). NORTH-STAR: a reason-required validation keeps focus on the radio group until a reason (or 'Other' + text) is supplied."
empty:
  render: "Not a list surface — a single-subscription cancel flow. The subscriptions-list empty state ('No subscriptions yet…') is owned by SubscriberPortalApp.svelte. NORTH-STAR edge: if the merchant has configured zero reasons, the form falls through to the interventions/confirm step rather than rendering an empty radio group."
inputs:
  - field: "cancel_reason"
    control: "radio"
    label: "NORTH-STAR 'Why are you cancelling?' radio group — NOT rendered today."
    allowed_values: "merchant-configured reason codes (e.g. dont-need-now / too-expensive / ordering-too-much / product-issue) PLUS a fixed 'Other' option; enumerable but data-driven per merchant, so it carries allowed_values rather than a static set."
    validation: "NORTH-STAR: a reason must be selected before 'Continue'; selecting 'Other' requires the free-text field below. TODAY: unvalidated — no reason field exists."
  - field: "other_reason_text"
    control: "text"
    label: "NORTH-STAR free-text shown only when 'Other' is selected — NOT rendered today."
    validation: "NORTH-STAR: required and non-empty when 'Other' is the selected reason; free text, not enumerable."
edge_status:
  - status: "no reason selected (north-star validation)"
    affordance: "'Continue' stays disabled and an inline 'select a reason' alert is shown; focus stays on the radio group until a reason is chosen."
  - status: "'Other' selected without text (north-star validation)"
    affordance: "the dependent free-text field is revealed and required; 'Continue' blocks until it is non-empty."
  - status: "merchant has zero configured reasons (north-star)"
    affordance: "skip the reason step and proceed directly to interventions/confirm — never a dead-end empty radio group."
disabled_focus:
  keyboard: "TODAY the cancel-flow controls are real <button>s reachable in tab order (the 'Cancel subscription', 'Still want to cancel', 'Go back', and 'Yes, cancel subscription' buttons; lines 214-222, 201-211, 132-139, 124-131) — never div-onClick. NORTH-STAR: the reason radio group is a native fieldset of <input type=radio> elements, Tab to enter and arrow keys to move between reasons, with a visible focus ring; choosing 'Other' reveals an associated <input type=text>."
  guard: "NORTH-STAR: selecting a reason is non-destructive; the destructive cancel still requires the explicit 'Yes, cancel subscription' confirm (lines 124-131)."
gaps: "Reason capture is ENTIRELY UNBUILT: no radio list, no 'Other', no cancel_reason field anywhere in storefront-svelte. cancelSubscription's signature (api-client.ts:118) and doCancel's POST body (lines 83-85) carry only declined_intervention_types, so structured reason data is never collected or persisted. Interventions are also shown BEFORE any reason (wrong order vs the BRD, which captures reason first). This missing field is the root that blocks the reason-gating ACs of US-20.2 / US-20.3 / US-20.4 (each keys its offer off the captured reason). Every reason-form element above is forward-looking with no current line numbers."

US-20.2: Intervention: pause-instead-of-cancel

<!-- traceability:start:US-20.2 -->

Prototype: Cancel Flow

<!-- traceability:end:US-20.2 -->

Phase: MVP · Priority: P0 · Effort: M · Persona: Subscriber

As a Subscriber citing "don't need right now," I want to be offered a pause, so that I don't fully cancel.

Acceptance criteria:

  • Given reason = "Don't need right now," When the flow proceeds, Then an offer screen shows "Pause 30/60/90 days" options.
  • Given I accept a pause, When I confirm, Then the sub pauses and the cancel flow ends.

UX notes.

  • Surface: cancel flow step 2, when reason = "Don't need right now"
  • Screen: copy addressing the stated reason ("Going on vacation? Just need a break?") + 3 pill options (Pause 30 / 60 / 90 days) + "No thanks, cancel anyway"
  • Accepting: apply pause via US-18.3, exit flow with confirmation
  • Declining: continue to confirm

Data contract.

  • Intervention config stored per merchant: {reason_code: pause_offer, durations: [30,60,90]}
  • On accept: invoke pause API + log churn.intervention_accepted
  • Events: churn.intervention_offered, churn.intervention_accepted/declined

Success metrics.

  • Product: pause-instead-of-cancel save rate ≥ 25% among "don't need now" reasons (target; calibrate per merchant)
  • Product (target): 70% of pause-intervention subscribers return to active after pause ends (vs. drop to 0 if cancelled)

Dependencies.

  • US-18.3 (pause implementation)
  • US-20.1 (reason capture)

Non-functional.

  • If subscriber accepts pause but also has a dunning failure, pause supersedes (halts dunning during pause)
<!-- normative-requirements US-20.2 - artifact: churn.intervention_offered kind: event fit: emitted when the pause intervention screen is shown during cancel flow closes: gap:#1718 - artifact: churn.intervention_declined kind: event fit: emitted when subscriber declines the pause offer and proceeds to cancel closes: gap:#1718 -->

UI states.

<!-- ui-states US-20.2 -->
surface: "Storefront subscriber portal (Svelte/Tailwind) — the pause intervention card inside the cancel funnel of CancelSubscriptionButton.svelte (lines 169-182), shown when the subscriber initiates cancel. Mounted on the portal active-subscription row (SubscriberPortalApp.svelte:439, with onPaused -> loadSubscriptions refresh, line 443). Persona: Subscriber."
idle:
  render: "Once the funnel opens (step='funnel', role='group', lines 143-148, heading 'Before you go — would any of these help?'), a white card renders 'Pause for 4 weeks' / 'Skip the next shipment and resume automatically.' (lines 169-173) with a solid 'Pause' button (lines 174-181)."
  primary_action: "'Pause' -> acceptPause() -> POST /api/v1/portal/subscriptions/{id}/pause (lines 44-57; api-client.ts pauseSubscription, called line 48)."
loading:
  trigger: "POST /pause while busy=true (acceptPause sets busy, lines 45-56)."
  render: "The 'Pause' button shows 'Pausing…' (line 180) and EVERY funnel control (Pause, Apply, 'Still want to cancel', the close X) is disabled via disabled={busy}; no double-submit."
error:
  surfaced_at: "Inline at the top of the funnel via role='alert' (lines 163-165), scoped to this subscription; the funnel stays open. err.message is shown raw (lines 52-53)."
  recovery: "Controls re-enable; the subscriber retries 'Pause', switches to the discount offer, or chooses 'Still want to cancel' to proceed to the cancel confirm (lines 201-211)."
empty:
  render: "Not a list surface — a single-subscription intervention card. The subscriptions-list empty state is owned by SubscriberPortalApp.svelte."
edge_status:
  - status: "pause accepted"
    affordance: "step='accepted' (role='status', lines 96-105) renders 'Subscription paused for 4 weeks.' + 'We'll resume your deliveries automatically.' (lines 99-100); onPaused refreshes the portal list (SubscriberPortalApp.svelte:443). Terminal success — the cancel is averted."
  - status: "pause declined"
    affordance: "'Still want to cancel' (lines 201-211) advances to the cancel confirm; the offer is not re-shown (one bounded round)."
  - status: "offer not gated by reason (north-star: shown only for reason='Don't need right now')"
    affordance: "NORTH-STAR: suppress the pause card unless the captured reason is 'Don't need right now' — depends on the US-20.1 reason-capture root, which is absent today, so the card currently shows unconditionally."
disabled_focus:
  keyboard: "The 'Pause' control is a real <button> (lines 174-181) reachable in tab order with a visible focus ring, Enter/Space-activatable; it is removed from interaction (disabled) while busy. The funnel close control is a real <button aria-label='Never mind, keep my subscription'> (lines 151-161)."
  guard: "Accepting a pause is non-destructive (it averts the cancel); the in-flight POST is double-submit-guarded by disabled={busy}."
gaps: "Pause duration is HARDCODED to 4 weeks (pauseSubscription({weeks:4}), line 48) — not the merchant-configurable 30/60/90-day pill options the BRD specifies, and there is no merchant config surface for durations. The offer is shown regardless of cancel reason (reason-gating root is US-20.1, unbuilt). churn.intervention_offered telemetry is fire-and-forget — logInterventionShown is called with .catch(()=>{}) (line 41), so a telemetry failure is swallowed silently; no churn.intervention_accepted/declined event is emitted from this component."

US-20.3: Intervention: offer discount

<!-- traceability:start:US-20.3 -->

Prototype: Intervention — Discount

<!-- traceability:end:US-20.3 -->

Phase: MVP · Priority: P0 · Effort: L · Persona: Subscriber / Merchant Admin

As a Subscriber citing "too expensive," I want to be offered a discount, so that I reconsider. As a Merchant Admin, I want to cap how often a subscriber can accept discounts, so that I don't train discount-seeking behavior.

Acceptance criteria:

  • Given reason = "Too expensive," When the flow proceeds, Then a merchant-configured discount (e.g., 15% off next 3 cycles) is offered.
  • Given I accept, When I confirm, Then the discount is applied and logged.
  • Given the subscriber has already accepted a discount intervention in the last N days (merchant config), When they return, Then the offer is suppressed.

Data contract.

  • Config: per-reason, per-merchant: {offer_type: percentage, offer_value: 15, applies_to_cycles: 3, max_acceptances_per_customer_per_year: 1}
  • On accept: apply a time-limited promotion (Epic 25) to the subscription; decrement available acceptances counter
  • Events: churn.intervention_accepted with metadata

Success metrics.

  • Product: discount-intervention save rate ≥ 20% (target; revise after 90d)
  • Product: post-discount churn rate vs. baseline — discount must not accelerate churn (abuse check)

Dependencies.

  • Epic 25 (promotion system delivers the discount)
  • Frequency capping (to prevent discount-farming)

UI states.

<!-- ui-states US-20.3 -->
surface: "Storefront subscriber portal (Svelte/Tailwind) — the discount intervention card inside the cancel funnel of CancelSubscriptionButton.svelte (lines 185-198), shown when the subscriber initiates cancel. Mounted on the portal active-subscription row (SubscriberPortalApp.svelte:439, with onDiscountApplied -> loadSubscriptions refresh, line 444). Persona: Subscriber / Merchant Admin."
idle:
  render: "Within the open funnel (step='funnel', lines 143-148) a white card renders 'Get 10% off your next renewal' / 'A one-time discount applied automatically at renewal.' (lines 185-189) with a solid 'Apply' button (lines 190-197)."
  primary_action: "'Apply' -> acceptDiscount() -> POST /api/v1/portal/subscriptions/{id}/discount (lines 59-72; api-client.ts applyOneTimeDiscount, called line 63)."
loading:
  trigger: "POST /discount while busy=true (acceptDiscount sets busy, lines 60-70)."
  render: "The 'Apply' button shows 'Applying…' (line 196) and all funnel controls are disabled via disabled={busy}; no double-submit."
error:
  surfaced_at: "Inline at the top of the funnel via role='alert' (lines 163-165), scoped to this subscription; the funnel stays open. err.message is shown raw (lines 67-68)."
  recovery: "Controls re-enable; the subscriber retries 'Apply', switches to the pause offer, or chooses 'Still want to cancel' to proceed to the cancel confirm (lines 201-211)."
empty:
  render: "Not a list surface — a single-subscription intervention card. The subscriptions-list empty state is owned by SubscriberPortalApp.svelte."
edge_status:
  - status: "discount accepted"
    affordance: "step='accepted' (role='status', lines 96-105) renders '10% discount applied to your next renewal.' + 'Your subscription continues at the discounted rate.' (lines 102-103); onDiscountApplied refreshes the portal list (SubscriberPortalApp.svelte:444). Terminal success — cancel averted."
  - status: "discount declined"
    affordance: "'Still want to cancel' (lines 201-211) advances to the cancel confirm; offer is not re-shown (one bounded round)."
  - status: "frequency-cap suppressed (north-star, AC3)"
    affordance: "NORTH-STAR: if the subscriber accepted a discount intervention within the merchant's last-N-days window, suppress this card and fall through to the next offer/confirm — UNBUILT today (no cap guard), so the offer is repeatable."
  - status: "offer not gated by reason (north-star: shown only for reason='Too expensive')"
    affordance: "NORTH-STAR: show the discount card only when the captured reason is 'Too expensive' — depends on the US-20.1 reason-capture root (absent today)."
disabled_focus:
  keyboard: "The 'Apply' control is a real <button> (lines 190-197) reachable in tab order with a visible focus ring, Enter/Space-activatable; disabled while busy. Tab order within the funnel: Pause -> Apply -> 'Still want to cancel' -> close X."
  guard: "Accepting a discount is non-destructive (it averts the cancel); the in-flight POST is double-submit-guarded by disabled={busy}."
gaps: "Discount is HARDCODED to a one-time 10% (applyOneTimeDiscount({percent:10}), line 63) — not the merchant-configured offer (e.g. 15% off next 3 cycles) the BRD specifies, and there is no merchant config surface. NO frequency cap exists (BRD AC3 'suppress if a discount was accepted in the last N days' / max acceptances per year is unimplemented), so the offer can be discount-farmed. The offer is shown regardless of reason (reason-gating root is US-20.1, unbuilt)."

US-20.4: Intervention: change cadence

<!-- traceability:start:US-20.4 -->

Prototype: Intervention — Cadence

<!-- traceability:end:US-20.4 -->

Phase: MVP · Persona: Subscriber

As a Subscriber citing "ordering too much," I want to switch to a longer interval, so that I don't have to cancel to reduce volume.

Acceptance criteria:

  • Given reason = "Ordering too much," When the flow proceeds, Then longer available intervals are offered.
  • Given I pick one, When I confirm, Then cadence changes and the flow ends.

UI states.

<!-- ui-states US-20.4 -->
surface: "NOT YET BUILT — forward-looking contract. Subscriber portal (Svelte/Tailwind) — cadence-change intervention card within the cancel funnel, surfaced as a step between reason capture (US-20.1) and the cancel confirm when the subscriber's stated reason is 'Ordering too much'. A 'No thanks, cancel anyway' control is always present at equal visual weight to the cadence CTA per the obstruction guard (US-20.7). Persona: Subscriber."
idle:
  render: "Within the open cancel funnel, a card renders: heading 'Getting too much, too fast?', subtext 'Switch to a less frequent delivery schedule — your price stays the same.' A <select> lists the plan's available intervals that are longer than the subscriber's current cadence (e.g. Every 2 months, Quarterly, Every 6 months, Annually). A 'Switch cadence' primary button submits the change. A 'No thanks, cancel anyway' link or button at equal visual prominence to 'Switch cadence' advances to the cancel confirm without making any change."
  primary_action: "Switch cadence → POST /api/v1/portal/subscriptions/{id}/interval with the selected longer interval; on success the cancel flow ends with a success confirmation."
loading:
  trigger: "POST /api/v1/portal/subscriptions/{id}/interval while the request is in-flight."
  render: "'Switch cadence' button shows 'Saving…' and is disabled; the interval select and 'No thanks, cancel anyway' control are also disabled for the duration of the in-flight POST; no double-submit."
error:
  surfaced_at: "Inline at the top of the cancel funnel card as a role=alert paragraph — never a toast."
  render: "The API failure reason (e.g. 'Couldn't update your delivery schedule. Try again.')."
  recovery: "The interval select and 'Switch cadence' button re-enable; the subscriber can retry with a different interval or click 'No thanks, cancel anyway' to proceed to the cancel confirm. One bounded round per US-20.7 obstruction guard."
empty:
  render: "When no intervals longer than the subscriber's current cadence are available for this plan (the subscriber is already on the maximum configured cadence), the cadence-change intervention card is not shown. The cancel funnel skips this step and proceeds to the next configured intervention or to the cancel confirm. This is a silent skip — no placeholder or error is displayed."
  cta: "n/a (card is suppressed when no longer intervals are available)"
edge_status:
  - status: "cadence change accepted"
    badge: "Updated"
    affordance: "The funnel transitions to a success card: 'Your delivery is now every [new interval]. No need to cancel.' A 'Done' close button ends the funnel. onCadenceChanged refreshes the portal subscription to reflect the new next_charge_at."
  - status: "cadence change declined ('No thanks, cancel anyway')"
    affordance: "The funnel advances to the cancel confirm step. The intervention card is not re-shown (one bounded round per US-20.7). The 'No thanks, cancel anyway' control completes this step in a single action."
  - status: "reason not 'Ordering too much' (north-star gating)"
    affordance: "This card is suppressed. The funnel shows a different intervention card or the cancel confirm based on the captured reason. Reason-gating requires US-20.1 reason capture to be built."
  - status: "subscriber already at maximum cadence (all plan intervals are at or shorter than current)"
    affordance: "Card is suppressed — identical to the empty state. The funnel moves to the next step with no error surfaced."
inputs:
  - field: "new_interval"
    control: "select"
    label: "Switch to"
    allowed_values: "plan intervals longer than the subscriber's current cadence; populated dynamically from plan config (e.g. Every 2 months | Quarterly | Every 6 months | Annually)"
disabled_focus:
  keyboard: "The interval <select> and 'Switch cadence' <button> are real focusable elements in tab order — never div-onClick; Tab-reachable with a visible focus ring; the <select> is navigable with arrow keys. 'No thanks, cancel anyway' is a real <button> or <a> reachable via Tab — never hidden, timed, or visually deprioritised relative to 'Switch cadence' per US-20.7 ARL obstruction guard."
  focus_move: "On success the funnel card transitions to the success message; the success heading or message is announced via aria-live=polite. On dismiss the funnel closes and focus returns to the subscription row's manage trigger."
  guard: "Switching cadence is non-destructive (averts cancel; price unchanged; next_charge_at recomputed server-side); no typed-confirm required. The in-flight POST is double-submit-guarded by disabled state."

US-20.5: Intervention: escalate to support

<!-- traceability:start:US-20.5 -->

Prototype: A/B Variants

<!-- traceability:end:US-20.5 -->

Phase: MVP · Persona: Subscriber

As a Subscriber citing "product issue," I want to reach support, so that the issue is resolved.

Acceptance criteria:

  • Given reason = "Product issue," When the flow proceeds, Then a merchant-configured support handoff happens (Gorgias ticket, email, phone link).

UI states.

<!-- ui-states US-20.5 -->
surface: "NOT YET BUILT — forward-looking contract. Subscriber portal (Svelte/Tailwind) — support-handoff intervention card or modal within the cancel funnel, surfaced when the subscriber's stated cancel reason is 'Product issue'. Routes to the merchant-configured support channel (Gorgias ticket creation, email, or phone link). A 'No thanks, cancel anyway' control is always present and equal visual weight to the support CTA per US-20.7 obstruction guard. Persona: Subscriber."
idle:
  render: "Within the open cancel funnel, a card or modal renders: heading 'Having a product issue? Let us help.', subtext reflecting the merchant-configured support message. The primary CTA presents the merchant's support channel: 'Open a support ticket' button (Gorgias), or 'Email us' mailto: link pre-filled with subscription ID and issue context, or 'Call us: [phone number]' tel: link. A 'No thanks, cancel anyway' button at equal visual weight is always shown — per US-20.7 it cannot be hidden, timed, or made less prominent than the support CTA."
  primary_action: "Contact support (channel-dependent): for Gorgias, POST /api/v1/portal/subscriptions/{id}/support-ticket to create a ticket; for email, the mailto: link opens the subscriber's mail client; for phone, the tel: link renders the number. On Gorgias success a ticket reference number is shown."
loading:
  trigger: "POST /api/v1/portal/subscriptions/{id}/support-ticket while the Gorgias ticket request is in-flight (not applicable for email or phone — those are client-side link actions)."
  render: "The 'Open a support ticket' button shows 'Connecting…' and is disabled during the Gorgias API call; the 'No thanks, cancel anyway' control is also disabled for the duration of the in-flight POST; no double-submit."
error:
  surfaced_at: "Inline beneath the primary support CTA as a role=alert paragraph — never a toast that vanishes."
  render: "The Gorgias API failure reason (e.g. 'Couldn't open a support ticket. Try emailing us instead.' with the email channel link shown as fallback if the merchant has configured one)."
  recovery: "The 'Open a support ticket' button re-enables; the subscriber can retry the Gorgias CTA, use the email fallback link if shown, or click 'No thanks, cancel anyway' to proceed to the cancel confirm. One bounded round per US-20.7."
empty:
  render: "If the merchant has not configured any support channel (no Gorgias integration, no email, no phone), the support escalation card is not rendered and the cancel funnel skips directly to the cancel confirm. No placeholder or error is shown — it is a silent step skip."
  cta: "n/a (card is omitted when no support channel is configured)"
edge_status:
  - status: "gorgias_ticket_created"
    badge: "Ticket opened"
    affordance: "The card transitions to: 'Support ticket #[N] opened — our team will follow up soon. Your subscription is on hold while we resolve this.' A 'Done' close button ends the funnel and the cancel is averted. A persistent 'Cancel anyway' link remains available in case the subscriber still wishes to cancel."
  - status: "email_handoff (mailto: link)"
    affordance: "The 'Email us' link opens the subscriber's email client pre-addressed and pre-filled with subscription ID and issue context. The card note reads 'You'll hear back within 24 hours.' The funnel remains open so the subscriber can cancel anyway after initiating the email."
  - status: "phone_handoff (tel: link)"
    affordance: "'Call us: [phone number]' renders as a real <a href='tel:...'> link. The card reads 'Give us a call — we'd love to help.' The funnel remains open so the subscriber can cancel anyway after noting the number."
  - status: "reason not 'Product issue' (north-star gating)"
    affordance: "This card is suppressed. The funnel shows a different intervention or the cancel confirm based on the captured reason. Reason-gating requires US-20.1 reason capture."
  - status: "no support channel configured (merchant)"
    affordance: "Card is omitted; funnel goes directly to the cancel confirm — identical to the empty state."
inputs: []
disabled_focus:
  keyboard: "The support CTA (Gorgias <button>, email <a href='mailto:'>, or phone <a href='tel:'>) is a real focusable element in tab order — never div-onClick. 'No thanks, cancel anyway' is a real <button> reachable via Tab at equal visual weight to the support CTA per US-20.7. When the handoff renders as a modal, focus moves into the modal on open (focus-trap on the modal container); on close focus returns to the cancel flow trigger in the subscription row."
  focus_move: "On Gorgias ticket success focus moves to the success heading or message (aria-live=polite announces 'Support ticket opened'). On error the role=alert paragraph is announced via aria-live=assertive."
  guard: "Opening a Gorgias ticket is non-destructive (it averts the cancel and opens a support thread); no typed-confirm required. The Gorgias CTA is double-submit-guarded by disabled state during the in-flight POST."

US-20.6: Intervention A/B testing

Phase: P3 · Persona: Merchant Admin

As a Merchant Admin, I want to A/B test intervention framing (messaging, offer depth), so that I maximize save rate.

Acceptance criteria:

  • Given I create variants in admin, When subscribers enter the flow, Then they're randomized and save rates are attributable per variant.

UI states.

<!-- ui-states US-20.6 -->
surface: "NOT YET BUILT — forward-looking contract. Merchant Admin (React/BigDesign) — cancel-flow settings page, 'Save-flow A/B tests' panel; Phase 3 feature allowing merchants to create named variants of cancel-flow intervention framing, configure traffic splits and max offer rounds, and view per-variant save-rate attribution. Architecture ADR not yet ratified. Persona: Merchant Admin."
idle:
  render: "A 'Save-flow A/B tests' BigDesign Panel shows a Table of configured variants: columns for Variant name, Intervention type, Traffic split (%), Status badge (Running / Paused / Draft), and Save rate (%). A permanent 'Control (no intervention)' baseline row is always present for comparison. A 'New variant' primary Button above the table opens the creation form. A Phase 3 notice BigDesign Message(type='info') renders while the ADR is unratified, with 'New variant' disabled in that state."
  primary_action: "New variant → opens the variant creation Panel or form; 'Save variant' commits via POST /api/v1/admin/cancel-flow/variants; on success the new variant appears in the table with Status='Draft'."
loading:
  plans: "On page mount, GET /api/v1/admin/cancel-flow/variants runs; while variants are loading, the Table shows 'Loading variants…' text — no skeleton."
  submit: "While saving, the 'Save variant' Button shows 'Saving…' with BigDesign isLoading; all variant form controls are disabled; no double-submit."
error:
  surfaced_at: "Two inline BigDesign Message(type='error') placements: a load-failure Message above the Table ('Couldn't load variants — reload the page.') and a save-failure Message inside the creation form above the 'Save variant' Button — never a toast."
  render: "The API or validation failure reason (e.g. 'Traffic splits across active variants must sum to 100%' or 'This configuration violates the obstruction-limit guard: offer rounds cannot exceed [N]' or 'Variant name is required')."
  recovery: "On load failure the merchant reloads the page. On save failure the form stays populated and re-enables; the merchant corrects the offending field (traffic split, max offer rounds, or name) and re-saves."
empty:
  render: "When no A/B test variants have been created, the Table shows only the 'Control (no intervention)' baseline row and copy 'No variants yet — create your first variant to start testing save-flow framing.' The 'New variant' Button is shown (disabled if the Phase 3 gate is unratified)."
  cta: "New variant — create the first intervention framing variant to begin A/B testing."
edge_status:
  - status: "architecture ADR not ratified (Phase 3 gate)"
    badge: "Coming in Phase 3"
    affordance: "The 'New variant' Button is disabled; a BigDesign Message(type='info') reads 'Save-flow A/B testing is a Phase 3 capability. The architecture ADR for randomised assignment and per-variant attribution must be ratified before variants can be activated.' Draft variants remain visible in the Table but cannot be set to Running."
  - status: "variant Running — live subscriber traffic"
    badge: "Running"
    affordance: "The variant's Intervention type and offer framing fields are read-only while live; the merchant can 'Pause' the variant (transitions to Paused, traffic falls to Control baseline) or 'View results' for attribution. Editing framing requires pausing first to preserve result integrity."
  - status: "variant Paused"
    badge: "Paused"
    affordance: "'Resume' Button restarts traffic assignment; 'Delete variant' permanently removes it after a BigDesign Modal confirmation. Traffic from a paused variant falls to the Control baseline."
  - status: "insufficient traffic for statistical significance"
    badge: "Low data"
    affordance: "A BigDesign Message(type='warning') on the variant row: 'Not enough data for significance — keep running to reach a reliable save-rate estimate.' No merchant action is required; continue running the test."
  - status: "traffic splits do not sum to 100%"
    affordance: "Inline validation beneath the traffic-split Inputs in the creation form: 'Traffic splits must total 100%. Adjust the percentages before saving.' The 'Save variant' Button stays disabled until the splits are valid."
  - status: "variant violates obstruction guard (US-20.7 — offer rounds exceed cap or cancel control removed)"
    affordance: "Inline validation in the creation form blocks save: 'This variant violates the obstruction-limit guard. Offer rounds cannot exceed [N] and the cancel control cannot be removed. Adjust your configuration to comply.' The variant is never deployed until corrected."
inputs:
  - field: "variant_name"
    control: "text"
    label: "Variant name"
    validation: "required, non-empty"
  - field: "intervention_type"
    control: "select"
    label: "Intervention type"
    allowed_values: "Pause offer | Discount offer | Cadence change | Support handoff"
  - field: "offer_framing"
    control: "textarea"
    label: "Offer framing copy"
    validation: "required; subscriber-facing headline and subtext shown in this variant"
  - field: "traffic_split_pct"
    control: "number"
    label: "Traffic split (%)"
    validation: "integer 0–100; all active variant splits must sum to 100%"
  - field: "max_offer_rounds"
    control: "number"
    label: "Max offer rounds"
    validation: "integer 1–N; platform-capped per US-20.7 obstruction guard"
disabled_focus:
  keyboard: "Variant Table rows' 'Pause', 'Resume', 'View results', and 'Delete variant' controls are real BigDesign Buttons reachable in tab order — never div-onClick. The 'New variant' Button, creation form Inputs (name, intervention-type Select, framing textarea, traffic-split number Input, max-rounds number Input), 'Save variant' Button, and 'Cancel' Button are all real BigDesign components in DOM tab order. Focus moves into the creation form when 'New variant' is clicked; on form close focus returns to the 'New variant' Button."
  focus_move: "On successful save a BigDesign Message(type='success') 'Variant created' renders and is announced via aria-live=polite. On validation error the save-failure Message is announced via aria-live=assertive. The delete-confirmation BigDesign Modal traps focus until the merchant confirms or cancels; on cancel focus returns to the 'Delete variant' Button."
  guard: "'Delete variant' is destructive (permanently removes the variant and its attribution history) and requires a BigDesign Modal confirmation with 'Delete' and 'Cancel' buttons before executing — no single-click deletion."

US-20.7: Churn-flow obstruction guard (save-attempt limit)

Phase: P1 · Priority: P0 · Effort: S · Persona: Subscriber / Merchant Admin

As a Subscriber, I want the cancel-time save flow to make at most a bounded, non-obstructive attempt to retain me, so that the retention experience never becomes a dark pattern — and as a Merchant, so that my Epic-20 save flows do not themselves violate automatic-renewal-law obstruction limits.

Compliance note (research red team S3, synthesis #1822, ADR-0079). The audit's sharpest finding: Epic 20 churn-prevention save flows, as originally specified, could themselves violate ARL save-attempt / obstruction limits — the spec shipped the retention feature without the compliance control. State ARLs (e.g., CA AB 2863, effective 2025-07-01) prohibit obstructing or unreasonably delaying cancellation. This story is the guard that keeps Epic 20 lawful; it composes with the click-to-cancel parity story (US-18.11). Final scope counsel-gated (ADR-0079).

Acceptance criteria:

  • Given a subscriber initiates cancel and a save flow runs (US-20.1–US-20.6), When the flow presents retention offers, Then it presents at most one bounded round of offers (a merchant-configurable cap, default one) — it cannot loop, re-prompt, or escalate indefinitely.
  • Given any save-flow step, When it renders, Then a live "Cancel anyway / No thanks" control completes the cancel in one action from that step — it is never hidden, disabled, timed, or made less prominent than the retention CTA.
  • Given the subscriber declines the offers, When they confirm cancel, Then the cancel executes immediately with no further interception (handoff to US-18.5 / US-18.11).
  • Given a merchant configures the save flow, When they set offer steps beyond the cap or remove the cancel control, Then the configuration is rejected at save time with an obstruction-limit validation error.

UX notes.

  • Surface: the Epic-20 churn-prevention flow + the merchant's save-flow configuration screen
  • The cancel control and the retention CTA are visually equal-weight on every step
  • Merchant-configurable cap exposes a hard ceiling (the platform default and the maximum are both bounded)

Data contract.

  • Config: save_flow.max_offer_rounds (merchant-set, platform-capped), save_flow.cancel_control_required = true (non-removable)
  • Emit churn.save_flow_shown + churn.cancel_completed with offer_rounds_shown so obstruction can be audited
  • No new BC platform primitive — a constraint on the existing Epic-20 flow

Success metrics.

  • Functional: 100% of save-flow steps render a live, one-action cancel control (a step without one is a defect that fails the obstruction guard)
  • Operational (target): obstruction-limit validation rejects an over-cap save-flow config in under 200ms at save time
  • Product (target): cancel-completion-after-decline is never blocked — measured as zero subscribers stuck in a save-flow loop

Dependencies.

  • US-20.1–US-20.6 (the churn-prevention flow this constrains)
  • US-18.5 / US-18.11 (cancel + click-to-cancel parity)
  • ADR-0079; counsel attestation

Non-functional.

  • The cap and the cancel-control requirement are enforced server-side at config-save and at flow-render — a non-compliant flow cannot be deployed
  • The guard is jurisdiction-aware via ADR-0079 (the cap can tighten where a stricter ARL applies)

Risks / open questions.

  • Where state ARLs differ on what counts as "obstruction," ADR-0079 sets the platform-conservative default; counsel confirms
  • Interaction with A/B-tested save flows (US-20.6) — every variant must satisfy the guard; the experiment cannot opt out

UI states.

<!-- ui-states US-20.7 -->
surface: "Storefront subscriber portal (Svelte/Tailwind) — the cancel-time churn funnel + confirm flow of CancelSubscriptionButton.svelte, the obstruction-guard contract over the whole step machine (step: idle -> funnel -> confirm -> accepted/idle, lines 30-93). Mounted on the portal active-subscription row (SubscriberPortalApp.svelte:439) and the active-only detail surface (SubscriptionDetailView.svelte:99). Persona: Subscriber / Merchant Admin."
idle:
  render: "An active subscription shows a single 'Cancel subscription' button (lines 214-222, data-demo='portal-cancel-btn'); activating it opens exactly one funnel round (openFunnel, lines 37-42)."
  primary_action: "Cancel is always reachable in one bounded round: 'Cancel subscription' (idle) -> 'Still want to cancel' (funnel, lines 201-211) -> 'Yes, cancel subscription' (confirm, lines 124-131, doCancel)."
loading:
  trigger: "doCancel -> POST /api/v1/portal/subscriptions/{id}/cancel (lines 79-93); or an in-flight intervention (acceptPause/acceptDiscount)."
  render: "While busy, the active step's primary button shows progress ('Cancelling…', line 130) and every control on the step is disabled via disabled={busy}; no double-submit and no timed/spinner gate that delays cancellation."
error:
  surfaced_at: "Inline role='alert', scoped to the subscription, on whichever step failed — confirm (lines 120-122) and funnel (lines 163-165); never a page-level banner or vanishing toast."
  recovery: "On failure all controls re-enable, so the one-action cancel control stays live: the subscriber retries 'Yes, cancel subscription' or presses 'Go back' (lines 132-139). A failed intervention never blocks the path to cancel."
empty:
  render: "Not a list surface — a single-subscription cancel flow. The subscriptions-list empty state is owned by SubscriberPortalApp.svelte."
edge_status:
  - status: "one bounded offer round (AC1)"
    affordance: "The step machine shows interventions exactly once (idle -> funnel); there is no loop or re-prompt path back into the funnel after decline, so offers cannot escalate indefinitely. NORTH-STAR: this 'one round' is merchant-configurable via save_flow.max_offer_rounds (UNBUILT — hardcoded to one)."
  - status: "cancel control present on every step (AC2)"
    affordance: "idle -> 'Cancel subscription'; funnel -> 'Still want to cancel' (lines 201-211); confirm -> 'Yes, cancel subscription' (lines 124-131). Each completes or advances the cancel in one action."
  - status: "decline -> immediate cancel (AC3)"
    affordance: "'Still want to cancel' -> confirm -> doCancel executes the cancel with no further interception (lines 74-93)."
  - status: "over-cap / cancel-control-removed merchant config (AC4, north-star)"
    affordance: "NORTH-STAR: reject at save time with an obstruction-limit validation error — UNBUILT; there is no save_flow config surface and no server-side guard."
disabled_focus:
  keyboard: "Every control is a real, keyboard-reachable <button> in tab order with a visible focus ring (the idle 'Cancel subscription', the funnel 'Pause'/'Apply'/'Still want to cancel'/close X, and the confirm 'Yes, cancel subscription'/'Go back'; lines 214-222, 174-197, 201-211, 151-161, 124-139) — no div-onClick dead-ends."
  guard: "The destructive cancel requires the explicit confirm step ('Yes, cancel subscription', lines 124-131); the in-flight POST is double-submit-guarded by disabled={busy}."
  focus_trap_gap: "The confirm step is role='alertdialog' (line 110) but has NO focus trap and NO Escape-key handler — focus is not moved into the dialog on open and Escape does not dismiss it, so the modal contract is only partially met."
gaps: "AC2 EQUAL-WEIGHT VIOLATION (ARL obstruction risk): the funnel-step cancel control 'Still want to cancel' is styled text-xs underline text-gray-400 (line 206) while the retention accept buttons are solid bg-gray-800 white font-semibold (lines 178, 190) — the cancel control is visually subordinate, not equal-weight. The funnel close X (aria-label 'Never mind, keep my subscription', lines 151-161) ABANDONS the cancel rather than completing it, so the only one-action cancel on the funnel step is the subordinate link. AC1 save_flow.max_offer_rounds merchant config and AC4 server-side over-cap / cancel-control-removed rejection are both UNBUILT (no save_flow config surface, no save-time validation). The confirm role='alertdialog' (line 110) lacks a focus trap and Escape handler."