fix(activation): route a denied push permission to the blocked state (#5609)#5615
Merged
Conversation
…5609) With notifications blocked at the browser level, the alerts step resolved to "DIDN'T WORK — Try again". Browsers never re-prompt once permission is `denied`, so that retry failed identically forever — a dead end the user could only escape by abandoning the step. `onConfirmStep` now resolves a third result, `blocked`. `confirmAlerts` returns it by reading the LIVE `getPushPermission()` after `subscribeToPush` rejects, rather than parsing the thrown message: `denied` is unrecoverable, while a merely dismissed prompt stays `default` and keeps its retry. The shell folds the result into `effectiveState(step)`, so a mid-flow block renders through the exact path a step blocked at mount already used — site-settings instructions, one "Continue", no retry CTA — and resolves as skipped rather than failed. A block is a platform refusal, not a user choice, so it also fires a distinct `pro-activation-step-blocked` funnel event; without it the denial cohort would be byte-identical to voluntary skips in the durable record. The `failedNote` option and `alertsFailedNote()` become unreachable once denials never land in the failed state, so both are removed; the `declinedNote` copy now serves the just-denied blocked note while `blockedNote` keeps the pre-denied one. Claude-Session: https://claude.ai/code/session_01SirbngHaZJc6i9jMJ5mxhF
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Owner
Author
|
Follow-up filed as #5617: the durable Convex snapshot ( |
This was referenced Jul 25, 2026
koala73
added a commit
that referenced
this pull request
Jul 26, 2026
…cket (#5617) (#5629) * feat(activation): record a browser-blocked step in its own durable bucket (#5617) PR #5615 made a denied notification permission move the activation wizard's alerts step into its blocked state, and closed the resulting signal loss at the EVENT level (`pro-activation-step-blocked`). The durable side stayed collapsed: a blocked step landed in `skippedSteps` with no marker, so anything querying persisted per-account state in Convex after the fact could not tell a browser refusal from a voluntary skip -- which is exactly what sizing the push-denial population and deciding whether re-prompting an account is worth anything both need. Adds a fourth bucket rather than widening an existing one, so behavioral consumers are untouched: - `ActivationStepOutcome` gains `blocked`. It maps to `pending` in the summary (never `failed` -- we never attempted a write), counts as `pending` in the exit event, and still shows the finish-setup chip. A record change, not UX. - `buildActivationOutcomeBuckets` gains `blockedSteps` and now routes every outcome explicitly: its `else` catch-all would have persisted the new outcome as a failed write. - `selectStepEvent('blocked')` returns `stepBlocked`, not `stepSkipped`. - New pure `selectAdvanceOutcome(state)` is the single seam for all three ways a step can be advanced past without a confirm (Continue, Escape/dismiss, never reached). Writing `blocked` on only the Continue arm would have made the bucket a sample of "denied users who clicked Continue". Steps blocked at mount now record identically to those blocked mid-flow. - `recordProActivationOutcome` and the schema accept `blockedSteps`: optional server-side for mixed deploys, validated for disjointness like every other bucket, and written on every snapshot (each one is a full replacement). - `ProActivationFlowDependencies.recordOutcome` now uses the service's own `ProActivationOutcomeSnapshot` instead of a hand-copied structural shape that silently described less the moment a bucket was added. All 11 mutations of the new logic were verified to redden a test. Claude-Session: https://claude.ai/code/session_011wvTSMvKG7hLkJgRezBJFi * fix(activation): close the review gaps on the blocked outcome bucket (#5617) Code-review follow-ups on the durable `blocked` bucket. Three of these were real defects in the first commit, two of them CI-red: - e2e/pro-activation.spec.ts asserted the exact outcome payload in two places (`toEqual` on the snapshot object, and `['skipped','skipped','skipped']` for the denied-permission flow). Both would have gone red. `npm run typecheck` does not cover e2e/ — its tsconfig includes only `src` — so nothing local caught this; the spec now passes (24/24, run against a real browser). - A mid-flow denial was never flushed. `handleConfirm`'s `blocked` arm updated in-memory state but, unlike the `verified` arm, never called `recordProgress()`. A subscriber denied by the browser who then closed the tab stayed recorded as a voluntary skip forever — precisely the cohort this bucket exists to size. The denial is now durable the instant it happens, which required raising MAX_PRO_ACTIVATION_OUTCOME_REVISION to `members.length + 2` (one extra progress write is now reachable). - The durable buckets and the funnel events counted different cohorts: a blocked step recorded as `blocked` but still emitted `stepSkipped`, so the funnel's skip count included denials while `skippedSteps` excluded them. A new `reportAdvanceEvent` seam routes the event off the resolved outcome, so each step emits exactly one: `stepSkipped` for a real skip, `stepBlocked` for a refusal. This also closes a gap that predates #5617 — a step blocked at MOUNT previously emitted only `stepSkipped`, leaving that cohort invisible on the event stream entirely. NOTE: this shifts the `step-skipped` baseline down by the denial cohort. That is the intended correction, not a side effect. Also: - A permanently failing outcome write now reports to Sentry instead of only `console.warn` — an invisible durable-write failure is the exact blind spot #5621 is filed about. - The mutation patches `blockedSteps` straight from the arg rather than coercing to `[]`. Convex removes a field patched as `undefined`, so a client too old to report blocked steps leaves the field ABSENT ("could not report") instead of asserting "none were blocked" — a claim that is false for a client that classified denials as skips. - ProActivationChip re-derived outcomes with its own ternary; it now shares `selectAdvanceOutcome`. Behavior is identical today, which is exactly how the two would have drifted. - Schema rollback note: once rows carry `blockedSteps`, deleting the field fails the Convex deploy. Revert the write path, not the schema line. - Four comments still claimed a blocked step resolves as `skipped`. Tests: +5 (mid-flow flush through the real shell, Sentry on permanent failure, progress-revision persistence, omit-clears-rather-than-strands, revision cap top-of-range). All 15 mutations of the new logic verified to redden a test. Claude-Session: https://claude.ai/code/session_011wvTSMvKG7hLkJgRezBJFi
benngee85
pushed a commit
to benngee85/VISTA
that referenced
this pull request
Jul 26, 2026
…cket (koala73#5617) (koala73#5629) * feat(activation): record a browser-blocked step in its own durable bucket (koala73#5617) PR koala73#5615 made a denied notification permission move the activation wizard's alerts step into its blocked state, and closed the resulting signal loss at the EVENT level (`pro-activation-step-blocked`). The durable side stayed collapsed: a blocked step landed in `skippedSteps` with no marker, so anything querying persisted per-account state in Convex after the fact could not tell a browser refusal from a voluntary skip -- which is exactly what sizing the push-denial population and deciding whether re-prompting an account is worth anything both need. Adds a fourth bucket rather than widening an existing one, so behavioral consumers are untouched: - `ActivationStepOutcome` gains `blocked`. It maps to `pending` in the summary (never `failed` -- we never attempted a write), counts as `pending` in the exit event, and still shows the finish-setup chip. A record change, not UX. - `buildActivationOutcomeBuckets` gains `blockedSteps` and now routes every outcome explicitly: its `else` catch-all would have persisted the new outcome as a failed write. - `selectStepEvent('blocked')` returns `stepBlocked`, not `stepSkipped`. - New pure `selectAdvanceOutcome(state)` is the single seam for all three ways a step can be advanced past without a confirm (Continue, Escape/dismiss, never reached). Writing `blocked` on only the Continue arm would have made the bucket a sample of "denied users who clicked Continue". Steps blocked at mount now record identically to those blocked mid-flow. - `recordProActivationOutcome` and the schema accept `blockedSteps`: optional server-side for mixed deploys, validated for disjointness like every other bucket, and written on every snapshot (each one is a full replacement). - `ProActivationFlowDependencies.recordOutcome` now uses the service's own `ProActivationOutcomeSnapshot` instead of a hand-copied structural shape that silently described less the moment a bucket was added. All 11 mutations of the new logic were verified to redden a test. Claude-Session: https://claude.ai/code/session_011wvTSMvKG7hLkJgRezBJFi * fix(activation): close the review gaps on the blocked outcome bucket (koala73#5617) Code-review follow-ups on the durable `blocked` bucket. Three of these were real defects in the first commit, two of them CI-red: - e2e/pro-activation.spec.ts asserted the exact outcome payload in two places (`toEqual` on the snapshot object, and `['skipped','skipped','skipped']` for the denied-permission flow). Both would have gone red. `npm run typecheck` does not cover e2e/ — its tsconfig includes only `src` — so nothing local caught this; the spec now passes (24/24, run against a real browser). - A mid-flow denial was never flushed. `handleConfirm`'s `blocked` arm updated in-memory state but, unlike the `verified` arm, never called `recordProgress()`. A subscriber denied by the browser who then closed the tab stayed recorded as a voluntary skip forever — precisely the cohort this bucket exists to size. The denial is now durable the instant it happens, which required raising MAX_PRO_ACTIVATION_OUTCOME_REVISION to `members.length + 2` (one extra progress write is now reachable). - The durable buckets and the funnel events counted different cohorts: a blocked step recorded as `blocked` but still emitted `stepSkipped`, so the funnel's skip count included denials while `skippedSteps` excluded them. A new `reportAdvanceEvent` seam routes the event off the resolved outcome, so each step emits exactly one: `stepSkipped` for a real skip, `stepBlocked` for a refusal. This also closes a gap that predates koala73#5617 — a step blocked at MOUNT previously emitted only `stepSkipped`, leaving that cohort invisible on the event stream entirely. NOTE: this shifts the `step-skipped` baseline down by the denial cohort. That is the intended correction, not a side effect. Also: - A permanently failing outcome write now reports to Sentry instead of only `console.warn` — an invisible durable-write failure is the exact blind spot koala73#5621 is filed about. - The mutation patches `blockedSteps` straight from the arg rather than coercing to `[]`. Convex removes a field patched as `undefined`, so a client too old to report blocked steps leaves the field ABSENT ("could not report") instead of asserting "none were blocked" — a claim that is false for a client that classified denials as skips. - ProActivationChip re-derived outcomes with its own ternary; it now shares `selectAdvanceOutcome`. Behavior is identical today, which is exactly how the two would have drifted. - Schema rollback note: once rows carry `blockedSteps`, deleting the field fails the Convex deploy. Revert the write path, not the schema line. - Four comments still claimed a blocked step resolves as `skipped`. Tests: +5 (mid-flow flush through the real shell, Sentry on permanent failure, progress-revision persistence, omit-clears-rather-than-strands, revision cap top-of-range). All 15 mutations of the new logic verified to redden a test. Claude-Session: https://claude.ai/code/session_011wvTSMvKG7hLkJgRezBJFi
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #5609.
The problem
From the live purchase repro in #5600: with notifications blocked at the browser level, the activation wizard's alerts step showed "DIDN'T WORK — Try again". Browsers never re-prompt once
Notification.permissionisdenied, so that button failed identically forever. The wizard already had a blocked state with site-settings instructions (#5534) — it just only rendered when permission was denied at mount.The fix
onConfirmStepresolves a third result,blocked:confirmAlertsclassifies by the live permission, not the error message. AftersubscribeToPushrejects it readsgetPushPermission():deniedis unrecoverable →blocked; a merely dismissed prompt leaves itdefault→ retryablefailed, so "Try again" survives where it still means something.effectiveState(step), so a mid-flow block renders through the exact path a mount-blocked step already used: blocked badge, site-settings note, a single "Continue", no retry CTA. It resolves asskipped— notfailed, which the summary would report as "we couldn't set this up" for something we never attempted to do.pro-activation-step-blocked). Since a blocked step resolves as skipped, without this the denial cohort would be byte-identical to voluntary skips in the durable record — a browser refusal would read as disinterest. The ConvexconfirmedSteps/skippedSteps/failedStepsbuckets are unchanged.failedNoteoption andalertsFailedNote()are unreachable, so both are deleted. ThedeclinedNotecopy now serves the just-denied note (it already pointed at site settings) whileblockedNotekeeps the pre-denied one — no orphaned locale keys.Copy for a user who just declined vs. one who arrived already blocked stays distinct, which is why
blockedNote(id, justDenied)takes the flag.Testing
Both halves were verified red before the fix and green after, and the shell fix was mutation-tested: disabling the
blockedbranch turns the e2e red, and makingfinalizeAndShowSummaryrecord blocked steps asfailedturns the new Escape test red.tests/pro-activation-alerts-blocked.test.mts(new, 8 tests) — drives the real module: the flow half assertsdenied → blocked,default → failed, and both post-subscribe failure paths (config read, rule write) staying retryable; the shell half asserts the rendered blocked state, the Escape/dismiss exit recordingskipped, the mount-blocked copy split, and that a plainfailedstill offers retry.The shell half needs a DOM that parses assigned
innerHTML(the wizard re-renders by assigning a string then wires buttons viaquerySelector), whichtests/helpers/mini-dom.mtsdeliberately does not do — three other test files depend on that non-parsing behavior, so the parser is layered on locally rather than changing the shared helper.e2e/pro-activation.spec.ts— a real-Chromium test of the same transition, mirroring the existing failed-then-Escape case. Full spec: 24/24 passing.typecheck,biome,lint:safe-html,sync:locales:check,docs-stats --check, and the activation/i18n/funnel-policy unit suites (174 tests) all green.Review
9 reviewers (6 always-on + adversarial + frontend-races, plus an independent cross-model adversarial pass via Codex/gpt-5.5). Four actionable findings, all applied:
innerHTMLgetter replayed the assigned string, so a post-render mutation would make assertions silently vacuouspro-activation-step-blockedeventAlso closed a latent trap both adversarial and maintainability raised below report threshold: two
step.statereads had not been migrated. Inert today, but they would fail silently the moment either branched on'blocked'— all reads now go througheffectiveState.Known residuals
confirmAlertsreadsgetPushPermission()after the rejection, assuming the spec guarantee thatNotification.permissionis committed beforerequestPermission()settles. On any engine that violated it, the classification would read a staledefaultand fall back to today's behavior — fails safe, but the fix would be inert there.failedrather thanblocked.blockedis unreachable today (onlyconfirmAlertscan produce it); the shell plumbing is generic but that branch is untested.disabledattribute (mini-dom does not mirror it), so the in-flight double-click guard rests on the e2e plus the flow'sinFlightset.Post-Deploy Monitoring & Validation
pro-activation-step-blockedUmami event. Expect a small non-zero volume from the moment the first denied-permission subscriber hits the wizard — it is the signal that was previously lost inside skips.pro-activation-step-blockedappears withstep=alerts;pro-activation-step-skippedfor alerts drops by roughly the blocked volume; no change topro-activation-exitcompletion mix.pro-activation-step-blockedevents after a week of traffic (classification never firing — the fix is inert), or a Sentry rise in[pro-activation] push subscribe declined/failedwith no matching blocked events.default-permission path) — that would mean the classification is too broad. Revert is a single commit.