The gate · when one of ~3 yearly requests is spent
Where: the goal-completion final surface only (goal-recap.html since the 2026-08-03 split, which is what the backdrop here now draws), 1.2s after the recap lands. Never after an ordinary lesson — that happens several times a week and would spend the year on a Tuesday.
All of these must hold: the run that finished the goal was full coverage (scoredCount == scoreEligibleCount, no uncertain part) · nothing failed in the session — no scorer outage, mic denial, below-floor take, offline retry or "báo sai" tap · the learner did not press Skip on the sequence (someone tapping past the celebration is not in the mood to be asked) · ≥ 3 distinct days of use, so a single-sitting learner is never asked · no metering wall seen in the last 7 days and no paywall or failed purchase in this session · at most one spend per 120 days, two per year, leaving the third in reserve. If the OS silently declines, nothing is drawn and nothing is retried.
Dev note · contract + escalation
The gate needs state no payload carries: distinctActiveDays, lastRatingRequestAt, ratingRequestsThisYear, lastMeteringWallAt — server-owned, because a device-local counter resets on reinstall and burns a request. Session-clean and skip-pressed are client facts, evaluated in the sequence. Escalation: the no-pre-prompt call above is a reversible default taken by design, not a ruling — marketing has a real interest in filtering who is asked.