Skip to content

v4.67 — Streak Insurance for Supporters

A Supporter whose habit streak broke yesterday can restore it from the card the app already shows them, at no XP cost, once a month — and a non-Supporter loses nothing they have today.

Summary

Roadmap item 48, the last open item in Phase 7 (Monetization & Teams), read as "add Supporter gating to the existing streak-freeze mutation." Planning found that premise wrong on both counts. GoalTracking::UseStreakFreeze has been proactive-only since v4.57 — it structurally cannot recover a streak that already broke, so gating it would gate the wrong feature. And recovering a broken streak was already shipped, twice, for free: Gamification::RepairStreak's once-per-goal-lifetime XP lane, and AcceptStreakMercy's once-ever free lane. The real ask was a third recovery lane — no XP cost, repeatable on an allowance, reserved to Supporters — and every free lane needed to stay exactly as it was, since taking one behind a paywall would be a dark pattern and a PBC-floor violation.

Eight phases across three domains shipped that lane. The backend (Phases 1, 2, 3b) added Gamification::ClaimStreakInsurance, reusing Gamification::StreakGapChecker and the existing 48-hour repair window rather than writing new streak math, and resolving the insurance offer independently of the free XP-lane offer so a goal that already spent its lifetime repair still surfaces an insurance option. Desi's UI-SPEC (Phase 3) ruled the card Dashboard-only and narrowed the upgrade prompt to exactly one state, catching a plan that would otherwise have summoned an upsell-only card for a user with no free option left. The frontend (Phases 4a, 4b, 4c) built the card, the upgrade moment, and — after a two-phase gap where the actual Dashboard wiring was never done despite a task package asserting it was — the live call site itself. Docs (Phase 5) closed with a human-escalated decision: streak insurance is the first Supporter perk that does something, which made docs/features/supporter.md's "purely cosmetic" framing false. Josh ruled the standing PBC commitment is "no features are ever paywalled," not "purely cosmetic" — the doc was corrected to that testable claim, and the milestone continued.

Ships behind streak_insurance_enabled at 0% rollout — this is complete build-out, not a launch. No user can reach the perk today.

Goal

A Supporter whose habit streak broke yesterday can restore it from the card the app already shows them, at no XP cost, once a month — and a non-Supporter looking at the same card loses nothing they have today and is told about the perk once, quietly, only at the moment it would actually have helped them.

Scope — What Shipped

  • Gamification::ClaimStreakInsurance (Interaction::Base) — Supporter gate, per-break guard, monthly allowance, reuses Gamification::StreakGapChecker and GoalTracking::UseStreakFreeze(system_granted: true) for the backfill. Never touches streak_repaired_count — a claim can never consume the free XP-repair allowance.
  • streak_insurance_claims table — unique index on (user_id, period_key), race-safe allowance enforcement; period_key computed in the user's own timezone.
  • claimStreakInsurance GraphQL mutation and eight new streakRepairOffer fields (userIsSupporter, insuranceEligible, insuranceClaimsRemaining, insurancePeriodResetsAt, insuranceGoalPublicId, insuranceGoalName, insurancePreBreakHabitStreak, insuranceEligibleUntil), resolved from an independent target lookup so the free XP-lane offer is never disturbed.
  • StreakRepairCard.vue — all eight UI-SPEC states, Storybook coverage per state; the free XP option is never hidden, de-emphasised, or shown below an upgrade prompt.
  • RepairConfirmationModal.vue gains a variant: 'xp' | 'insurance' prop — the insurance claim confirms (unlike the XP lane's renewable-resource skip) because the monthly allowance is scarce and irreversible.
  • SupporterUpgradeModal.vue gains a fourth perk row and corrected footer copy — no string anywhere still calls Supporter perks "cosmetic."
  • Dashboard.vue wired end-to-end in Phase 4c: the insurance props, the modal mount, and the emit listeners that the first two frontend phases had built but never connected.
  • streak_insurance_enabled — PostHog flag, fail-closed, registered client- and server-side, created at 0% in the same PR that introduced it.
  • docs/operations/rollouts/v4.67-streak-insurance.md — new ramp plan and kill-switch runbook.
  • docs/features/supporter.md rewritten per Josh's PBC ruling; docs/development/gotchas.md's native-copy contract widened to cover functional perks.

Phases

PhaseNameStatusPlansHighlights
1The entitlement and the claimShipped1ClaimStreakInsurance + streak_insurance_claims; streak_repaired_count deliberately untouched (D5)
2GraphQL surfaceShipped1claimStreakInsurance mutation + eight offer fields; D8's independent two-target lookup
3UI-SPEC — insurance lane and upgrade momentShipped1Eight-state design contract (Desi); Ruling 3 narrowed the upgrade line to one state on anti-dark-pattern grounds
3bComplete the insurance offer payloadShipped1Closed S4's missing streak-number/expiry rendering gap
4aBuild the insurance laneShipped1Card built for all eight states; flag registered at 0%
4bThe upgrade moment and the checkout return pathShipped1Perk row + corrected footers; Roy caught a MAJOR scoping bug (offer-level vs. surface-level predicate)
4cWire the live call siteShipped1Fixed the gap where Dashboard.vue never actually mounted the wiring the first two frontend phases built
5Docs reconciliation and roadmap ledgerShipped1Josh's PBC ruling on "purely cosmetic"; item 54 correctly left open against a stale ROADMAP instruction

Key Decisions

  • D1 — UseStreakFreeze is not gated and not modified. Proactive freezes stay free for everyone; gating them would gate the wrong feature and paywall something already free.
  • D2 — Streak Insurance is a new lane on existing machinery. No new streak-gap math — StreakGapChecker and UseStreakFreeze(system_granted: true) are reused verbatim, the same discipline OBJ-3040 established.
  • D4 — The allowance is one claim per calendar month, for every Supporter tier. "Per billing period" doesn't survive contact with the tier table — a yearly Supporter's period is a year, and lifetime has none at all.
  • D5 — A claim must never consume the free lane's allowance. RepairStreak's guard is a lifetime counter; if a claim incremented it, paying would remove free value. The claim clears the repair window and backfills the gap instead, leaving streak_repaired_count untouched.
  • D8 — The offer resolver needs two independent target lookups, not one widened scope. Widening the existing XP-lane scope would change which goal a non-Supporter sees an offer for — a real regression. Two independent lookups resolve to the same goal in the common case and diverge safely otherwise.
  • Ruling 3 (Phase 3, Desi) — the upgrade line appears in exactly one state, not two. The original plan would have summoned a card, for the first time, for a user with no free option left — a textbook upsell-only terminal state.
  • The confirm step stays for the insurance claim (Phase 4c). The monthly allowance is scarce and irreversible, unlike the XP lane's renewable resource — spending it deserves a confirmation the XP lane's own design choices don't require.
  • "No features are ever paywalled," not "purely cosmetic" (Phase 5, Josh's ruling). Streak insurance is the first Supporter perk that does something; the standing PBC commitment survives as a testable claim — a perk is never required, and no free capability is ever removed or degraded to sell it back.

Requirements Coverage

50 / 50 requirements satisfied (from v4.67-streak-insurance-for-supporters-MILESTONE-AUDIT.md).

PhaseCountStatus
1 — Entitlement and claim7All satisfied
2 — GraphQL surface8All satisfied
3 — UI-SPEC4All satisfied
3b — Complete the offer payload4All satisfied
4a — Build the insurance lane7All satisfied
4b — Upgrade moment + checkout return7All satisfied
4c — Wire the live call site8All satisfied
5 — Docs reconciliation5All satisfied (item 54 satisfied by correction, not by the ROADMAP's own stale instruction — see the audit's Deviations section)

Outcomes

Every free streak-recovery path — proactive freezes, XP repair, streak mercy — is provably unmodified: none of use_streak_freeze.rb, repair_streak.rb, or accept_streak_mercy.rb were touched by any phase-carrying commit in this milestone. A Supporter now has a fully-wired, source-verified path to restore a broken streak for free, once a month, indistinguishable in mechanism from the free XP lane beside it. docs/features/supporter.md now makes a testable commitment instead of a claim this milestone made false.

Nothing is reachable by a real user yet. streak_insurance_enabled sits at 0% rollout. The next step is a ramp decision, not more build work — see the rollout runbook for the plan and kill-switch procedure.

Tech Debt

  • (Phase 5, OBJ-4058) docs/product/user-guide.md §15 still carries the pre-ruling "purely cosmetic" framing — parked in backlog, gated on the flag ramping above 0%, same as the deferred user-facing changelog entry.
  • (ROADMAP § Open questions 2) The success-metric PostHog funnel ("30%+ of Supporters use streak insurance at least once") doesn't exist yet. Events are named and emitted; the dashboard itself is not built.
  • (pre-existing, Keystone E35) gamification.streak_repaired has no confirmed subscriber, now shared by two publishers instead of one — this milestone documented the gap rather than papering over it, per its own instruction not to assert a subscriber that doesn't exist.
  • 5ed7abed — Phase 1: entitlement + claim (PR #3370)
  • 93e0a77b — Phase 2: GraphQL surface (PR #3375)
  • 8612d2f7 — Phase 3: UI-SPEC (PR #3376)
  • 2acf3d86 — Phase 3b: complete the offer payload (PR #3379)
  • 7085c797 — Phase 4a: build the insurance lane (PR #3381)
  • a352b5e06 — Phase 4b: upgrade moment + checkout return (PR #3386)
  • b6826472e — Phase 4c: wire the live call site (PR #3402)
  • 27d7606b — Phase 5: docs reconciliation + roadmap ledger (PR #3406)

Last updated: 2026-09-20 Version: v4.9.0 (this milestone ships behind a 0%-rollout flag; no version tag yet)

Loading…