Skip to content

fix(activation): route a denied push permission to the blocked state (#5609)#5615

Merged
koala73 merged 1 commit into
mainfrom
fix/5609-alerts-blocked-state
Jul 25, 2026
Merged

fix(activation): route a denied push permission to the blocked state (#5609)#5615
koala73 merged 1 commit into
mainfrom
fix/5609-alerts-blocked-state

Conversation

@koala73

@koala73 koala73 commented Jul 25, 2026

Copy link
Copy Markdown
Owner

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.permission is denied, 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

onConfirmStep resolves a third result, blocked:

  • confirmAlerts classifies by the live permission, not the error message. After subscribeToPush rejects it reads getPushPermission(): denied is unrecoverable → blocked; a merely dismissed prompt leaves it default → retryable failed, so "Try again" survives where it still means something.
  • The shell folds the result into 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 as skipped — not failed, which the summary would report as "we couldn't set this up" for something we never attempted to do.
  • A block fires its own funnel event (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 Convex confirmedSteps/skippedSteps/failedSteps buckets are unchanged.
  • Dead code removed. Once denials never reach the failed state, the failedNote option and alertsFailedNote() are unreachable, so both are deleted. The declinedNote copy now serves the just-denied note (it already pointed at site settings) while blockedNote keeps 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 blocked branch turns the e2e red, and making finalizeAndShowSummary record blocked steps as failed turns the new Escape test red.

  • tests/pro-activation-alerts-blocked.test.mts (new, 8 tests) — drives the real module: the flow half asserts denied → 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 recording skipped, the mount-blocked copy split, and that a plain failed still offers retry.
    The shell half needs a DOM that parses assigned innerHTML (the wizard re-renders by assigning a string then wires buttons via querySelector), which tests/helpers/mini-dom.mts deliberately 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.
  • Gates: 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:

Finding Fix
P2 — no test pinned the Escape/dismiss exit while blocked (flagged by testing, adversarial, and maintainability independently) Added the test; mutation-verified it goes red
P2 — the test DOM's innerHTML getter replayed the assigned string, so a post-render mutation would make assertions silently vacuous Getter now serializes the live tree, attribute-faithfully
P3 — a browser block became indistinguishable from a voluntary skip in the durable record Added the pro-activation-step-blocked event
P3 (cross-model) — the "write error" test actually exercised the config-read path, never the write Split into two tests; the write path now genuinely fails and is asserted

Also closed a latent trap both adversarial and maintainability raised below report threshold: two step.state reads had not been migrated. Inert today, but they would fail silently the moment either branched on 'blocked' — all reads now go through effectiveState.

Known residuals

  • confirmAlerts reads getPushPermission() after the rejection, assuming the spec guarantee that Notification.permission is committed before requestPermission() settles. On any engine that violated it, the classification would read a stale default and fall back to today's behavior — fails safe, but the fix would be inert there.
  • Permission revoked in the narrow window between a successful subscribe and the later config write still yields failed rather than blocked.
  • A non-alerts step returning blocked is unreachable today (only confirmAlerts can produce it); the shell plumbing is generic but that branch is untested.
  • The unit harness cannot observe the disabled attribute (mini-dom does not mirror it), so the in-flight double-click guard rests on the e2e plus the flow's inFlight set.
  • The i18n stub returns keys, so unit tests pin which copy key is chosen; that the copy actually says "site settings" is asserted only in the e2e.

Post-Deploy Monitoring & Validation

  • Watch: the new pro-activation-step-blocked Umami 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.
  • Healthy: pro-activation-step-blocked appears with step=alerts; pro-activation-step-skipped for alerts drops by roughly the blocked volume; no change to pro-activation-exit completion mix.
  • Failure signals: zero pro-activation-step-blocked events after a week of traffic (classification never firing — the fix is inert), or a Sentry rise in [pro-activation] push subscribe declined/failed with no matching blocked events.
  • Rollback trigger: any report of the alerts step losing its retry for a recoverable failure (the default-permission path) — that would mean the classification is too broad. Revert is a single commit.
  • Window/owner: 7 days, @koala73.

…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
@vercel

vercel Bot commented Jul 25, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment Jul 25, 2026 9:50pm

Request Review

@koala73

koala73 commented Jul 25, 2026

Copy link
Copy Markdown
Owner Author

Follow-up filed as #5617: the durable Convex snapshot (confirmedSteps/skippedSteps/failedSteps) still has no blockedSteps bucket, so an after-the-fact query of persisted per-account state cannot tell a browser denial from a voluntary skip — only live listeners on the new pro-activation-step-blocked event can. Split out because it is a server-contract change (Convex validator + existing rows) and the event-level fix already covers funnel analysis and live monitoring.

@koala73
koala73 merged commit 8d22550 into main Jul 25, 2026
26 checks passed
@koala73
koala73 deleted the fix/5609-alerts-blocked-state branch July 25, 2026 21:52
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Activation alerts step: 'Try again' is a dead end when notification permission is browser-denied

1 participant