feat/discovery
needs attentionviewing older commit9f8dbdf · incrementalpre-PRreviewed 2026-07-11 02:32 UTC3H · 5M · 5L · 3I- Purpose
- Feature Discovery v3 — contextual spotlights (coachmarks) that guide providers through onboarding setup: CFE credential → first contract → bill monitoring → payment monitoring → internal site IDs → export columns → API keys
- Goal
- Implement persistent gap badge (Descubre hub) showing how many features remain unactivated, plus self-initiated spotlight pulls — so discovery is both push (daily spotlight) and pull (user opens Descubre menu)
- Sub-goals
- SG-1: Core decision engine — pure eligibility + pacing + retirement logic
- SG-2: Descubre hub — persistent badge showing gap count, user can click any eligible feature to get that spotlight on demand
- SG-3: Bug fixes — cfe_setup anchor, add_contract signal, pacing relax on previews, coachmark layout, idempotent secrets, clicking never retires (this commit)
- What
- This commit removes `|| exposure.clickedAt !== null` from the isRetired predicate and wraps the seenCount check in `clickedAt === null`. Updates two tests to match (rewrites the 'retires a clicked key' test to assert the opposite, adds a shows-based guard exemption test). Updates the schema comment.
- Why
- Production incident: a user clicked the cfe_setup spotlight (which suppresses all other spotlights when absent) and the AWS credential creation failed. The old code retired the key on click, leaving the user with gap=[], no spotlight, no badge — permanently. The fix makes live-state adoption (hasCfeCredential, hasContracts, etc.) the sole natural terminator; dismissal is the explicit user exit.
- Areas
- domains/cross-domain+574−0domains/core/src/discovery+449−0apps/platform/src/components/discovery+440−0apps/platform/src/api+278−0packages/database/src/schema+95−0e2e/platform+48−0packages/analytics+42−0packages/secrets/src/client+48−12apps/platform/src/app+26−2scripts+30−0
- Blast
- 44 files, +2893/-14 lines. Scoped to new Feature Discovery domain (new tables, new contracts, new UI). Minimal risk to existing features — touches sidebar/header for badge count, bills/contratos layout for spotlight anchors, and credential page. No schema migrations to existing tables.
Findings · 15
correctness4
Clicked cards have no upper bound — permanently-failing actions cause infinite reappearance
domains/cross-domain/src/feature-discovery.decisions.ts:151
Once clickedAt is set, the seenCount >= RETIRE_AFTER_UNCLICKED_SHOWS guard is permanently bypassed. If the downstream action keeps failing (e.g., a persistently broken AWS endpoint, plan limitation), the card reappears every day with no escape short of explicit dismissal. Consider a RETIRE_AFTER_TOTAL_SHOWS cap (e.g., 10) applied unconditionally to bound the zombie-card scenario, or at minimum document that dismissal is the intended escape hatch in this scenario.
Test change from 'internal_site_ids' to 'monitoring' could mask a priority-ordering regression
domains/cross-domain/src/__tests__/feature-discovery.decisions.test.ts:195
The old test verified priority fallthrough (dismissal retires 'monitoring' → 'internal_site_ids' is returned). The new test only verifies that clicking doesn't retire. No test explicitly covers that dismissal-triggered fallthrough still works correctly after this change. The existing dismissal retirement test covers it implicitly, but a targeted assertion would be clearer.
isBudgetSpent relies on 'seen' event preceding 'clicked' — no enforcement of ordering
domains/cross-domain/src/feature-discovery.decisions.ts:158
The 20h cooldown for a clicked-but-failed card depends on a 'seen' outcome having been recorded before 'clicked'. If a client sends 'clicked' without a preceding 'seen' (race condition or replay), lastSeenAt remains stale and the pacing guard may not block re-display in the same session. Pre-existing gap, but amplified now that clicks no longer retire cards.
Schema comment updated correctly to match new semantics
packages/database/src/schema/discovery-state.ts:14
Comment now accurately reflects the new retirement rules. No action needed.
security2
Non-admin user could set clickedAt on adminOnly key if shown via stale client state
domains/cross-domain/src/__tests__/feature-discovery.decisions.test.ts
adminOnly filtering is enforced in decideSpotlight (pure decision). If a non-admin user somehow sends a 'clicked' outcome for an adminOnly key (e.g., via direct API call or stale client render), their discovery state would record clickedAt. The card now never retires until dismissal. Impact is cosmetically scoped to their own UX state — no privilege escalation to the underlying feature — but worth verifying that the click outcome endpoint validates admin status before recording clickedAt.
No new PII or sensitive data introduced by this change
domains/cross-domain/src/__tests__/feature-discovery.decisions.test.ts
The isRetired change and new tests operate entirely on DiscoveryState fields (clickedAt, seenCount, dismissCount). No user PII, credentials, or sensitive business data is introduced.
conventions1
Schema comment continuation line missing JSDoc asterisk prefix
packages/database/src/schema/discovery-state.ts:15
The continuation of the added comment uses a leading space instead of ' * ' prefix, breaking the JSDoc block style used everywhere else. Should be: ` * (adoption ends a card naturally; a failed action must be retryable)`.
tests5
Regression test uses wrong key — cfe_setup is the actual prod bug, monitoring doesn't reproduce it
domains/cross-domain/src/__tests__/feature-discovery.decisions.test.ts:189
The production bug was 'clicked cfe_setup + AWS failure bricked the whole surface'. cfe_setup is the adminOnly gate signal — its retirement suppresses everything else. The test uses 'monitoring', which is not a gate signal and doesn't reproduce the full failure path. A cfe_setup-keyed test case with clickedAt set should be added to prevent regressions of the specific live incident.
Missing test: dismissed AND clicked key — dismissCount threshold must still retire regardless of clickedAt
domains/cross-domain/src/__tests__/feature-discovery.decisions.test.ts
isRetired returns true when dismissCount >= RETIRE_AFTER_DISMISSALS regardless of clickedAt. This edge case — a card that was clicked AND then dismissed N times — is the load-bearing invariant separating 'permanent exit via dismissal' from 'temporary exit via click'. Without a test, a future refactor adding clickedAt short-circuit logic could inadvertently exempt clicked keys from dismissal-based retirement.
No test: clicked card + fresh lastSeenAt still blocked by daily budget
domains/cross-domain/src/__tests__/feature-discovery.decisions.test.ts
There is no test verifying that a clicked card with a fresh lastSeenAt (within the 20h pacing window) is correctly suppressed by the budget guard. If the budget check is accidentally skipped for clicked keys, the spotlight could show every page load after a failed action. Stale vs fresh lastSeenAt cases for clicked cards should be explicitly covered.
listEligibleSpotlights not tested with new clicked-card-still-eligible semantics
domains/cross-domain/src/__tests__/feature-discovery.decisions.test.ts
The diff only changes decideSpotlight tests. listEligibleSpotlights (which powers the gap badge count in the Descubre hub) uses the same isRetired predicate. If a stale call-site copy exists or the predicate is re-evaluated differently, the old retire-on-click behavior could persist in the badge while the spotlight itself is fixed. A test confirming a clicked card still appears in the eligible list should be added.
Shows-based guard exemption test lacks a counterpart asserting that the same seenCount without clickedAt would retire
domains/cross-domain/src/__tests__/feature-discovery.decisions.test.ts:196
The 'clicked keys are exempt from shows-based guard' test proves the exemption holds but not that it's actually doing anything. Without a companion case (same seenCount, clickedAt=null, key is retired), the test would pass even if the seenCount guard was inadvertently removed.
improvement3
Missing isAdopted abstraction makes the retirement invariant comment-enforced, not structural
domains/cross-domain/src/feature-discovery.decisions.ts
The contract 'adoption is detected via live state, not clickedAt' is enforced only by the absence of a clickedAt check and a comment. A future contributor adding a new key could re-introduce clickedAt-based retirement without violating any type contract. Extracting an isAdopted(key, signals) predicate and having isRetired not depend on clickedAt would make this invariant structural.
Comment should explicitly call out that shows-based guard is suspended once clicked
domains/cross-domain/src/feature-discovery.decisions.ts:148
The updated comment says 'Clicking NEVER retires a key' but doesn't state the corollary: once clicked, the seenCount cap is inert, and the only retirement path is dismissal. Adds a sentence like: 'A clicked card ignores the unclicked-shows cap and only retires via RETIRE_AFTER_DISMISSALS dismissals.'
RETIRE_AFTER_UNCLICKED_SHOWS constant name remains precise after the change
domains/cross-domain/src/feature-discovery.decisions.ts:135
The constant name accurately describes its new semantics (only applies when the card was never clicked). No rename needed.
History · 36 commits
- c1fe7b7needs attentionincremental3H · 4M · 3L2026-07-27 06:02
- 414db56safeincremental0H · 0M · 2L2026-07-23 00:52
- ea68968needs attentionincremental0H · 4M · 7L2026-07-22 23:01
- 4bdb2c9needs attentionincremental1H · 3M · 5L2026-07-21 23:22
- 6e3f255safeincremental0H · 0M · 0L2026-07-20 16:46
- cc41079blockedincremental5H · 5M · 3L2026-07-20 16:25
- 2451ee0needs attentionincremental0H · 5M · 6L2026-07-20 16:09
- ab7c103needs attentionincremental0H · 1M · 6L2026-07-20 15:52
- 7eff611needs attentionincremental0H · 1M · 2L2026-07-18 00:41
- 246c67csafeincremental0H · 0M · 0L2026-07-17 23:58
- 7521b17safeincremental0H · 0M · 0L2026-07-17 23:35
- 1847ec8needs attentionincremental0H · 2M · 4L2026-07-17 23:28
- 5d3175cneeds attentionincremental3H · 6M · 4L2026-07-17 19:31
- d3f875dneeds attentionincremental1H · 2M · 4L2026-07-17 18:01
- 47d25faneeds attentionincremental1H · 1M · 2L2026-07-17 00:46
- 440d838needs attentionincremental4H · 9M · 9L2026-07-17 00:27
- 7466f97needs attentionincremental0H · 1M · 5L2026-07-16 23:20
- b686d58needs attentionincremental0H · 2M · 2L2026-07-16 14:24
- 9ea1446blockedincremental2H · 7M · 10L2026-07-16 13:44
- 6f39bb9needs attentionincremental0H · 2M · 7L2026-07-14 21:53
- 2eadf2aneeds attentionincremental0H · 1M · 2L2026-07-14 20:12
- 97bd08fneeds attentionincremental0H · 2M · 5L2026-07-14 19:40
- a48e3ffneeds attentionincremental5H · 8M · 6L2026-07-14 18:22
- 69473b7needs attentionincremental2H · 5M · 3L2026-07-14 01:34
- 92e4235needs attentionincremental2H · 1M · 3L2026-07-14 01:04
- 6be8018safeincremental0H · 0M · 3L2026-07-14 00:15
- 090b4daneeds attentionincremental1H · 3M · 5L2026-07-13 23:50
- fa7683bneeds attentionincremental1H · 4M · 3L2026-07-13 18:55
- d64cd72needs attentionincremental2H · 3M · 2L2026-07-13 16:27
- c1f337bneeds attentionincremental3H · 7M · 14L2026-07-13 13:30
- 46e32b2safeincremental0H · 0M · 3L2026-07-11 02:54
- 3b30949needs attentionincremental0H · 2M · 3L2026-07-11 02:39
- 9f8dbdfneeds attentionincremental3H · 5M · 5L2026-07-11 02:32current
- 23191eeneeds attentionincremental5H · 12M · 7L2026-07-11 02:18
- fa9b88fneeds attentionincremental0H · 1M · 2L2026-07-11 01:30
- 6a8bf42blockedfull8H · 11M · 8L2026-07-11 01:08