feat/discovery
needs attentionviewing older commitc1f337b · incrementalpre-PRreviewed 2026-07-13 13:30 UTC3H · 7M · 14L · 8I- Purpose
- Feature discovery system for Batu providers — guided tour + spotlight coachmarks to help new users adopt CFE credentials, RPU registration, and monitoring on first run.
- Goal
- Ship first-run linear product tour (Diego's v1): fixed 5-step sequence anchored to Contratos page, server-backed first-run detection, re-launchable from Descubre hub; plus two prod bug fixes (VERCEL_ENV preview gating, spotlight self-destruct).
- Sub-goals
- SG-1: VERCEL_ENV fix — replace insecure Host-header preview check with VERCEL_ENV=preview (build-time, not client-controllable)
- SG-2: Spotlight latch — keep shown card stable across the seen-POST self-invalidating refetch that made cards vanish sub-second in prod
- SG-3: Atomic upsert — replace fetch-decide-insert-or-update (race + stale lock) with INSERT ON CONFLICT DO UPDATE using SQL deltas
- SG-4: Gate keys never retire — cfe_setup and add_contract exempt from retirement (cross-org exposure would kill onboarding)
- SG-5: product_tour budget exclusion — completing the tour must not silence the ambient daily spotlight budget
- SG-6: GuidedTour component — 5-step overlay with spotlight cutout, Hazlo ahora dispatch, first-run + forced re-launch
- What
- Added GuidedTour.tsx (315 lines), tour-catalog.ts, spotlight latch (computeSpotlightLatch); refactored discovery upsert from insert/updateWithVersion to upsertOutcome; added tourSeen to gap response; fixed isPreviewHost to isPreviewDeployment; added gate-key never-retire + product_tour budget exclusion; new analytics events; new tests for latch, gate exemption, and createSecret idempotency.
- Why
- Diego's v1 product tour direction; concurrent with fixing two prod-blocking bugs found in pre-merge review.
- Areas
- apps/platform/src/components/discovery+700−30domains/core/src/discovery+320−120domains/cross-domain/src+940−50packages/api/src/schemas+10−0packages/analytics/src+70−0packages/database/src/schema+110−5packages/secrets+150−10apps/platform/next.config.ts+11−0
- Blast
- 50 files, +3630/−31 across discovery domain, cross-domain coordinator, API schemas, analytics, DB schema, and secrets. Touches dashboard layout (GuidedTour mount), gap/spotlight API response (tourSeen field), and the discovery_state upsert path used by every spotlight event.
Findings · 27
correctness3
openedRef blocks hub re-launch after first open in same session
apps/platform/src/components/discovery/GuidedTour.tsx:92
GuidedTour mounts in the layout and never unmounts. openedRef.current is set to true on first open and never reset. A second hub re-launch (?tour=1) in the same session silently does nothing. Fix: if (!forced && openedRef.current) return; and reset openedRef.current = false on forced re-launch.
Anchor-locate and close() can double-invoke outcome report
apps/platform/src/components/discovery/GuidedTour.tsx:163
If locate() fires its last retry concurrently with a user clicking Skip, both paths call report(). Upsert is idempotent (COALESCE on clickedAt, additive deltas of 0), so no data corruption, but two requests fire.
tourSeen is user-scoped, not org-scoped
domains/cross-domain/src/feature-discovery.shells.ts:154
A user who saw the tour in org A has tourSeen=true in org B. Consistent with acknowledged cross-org staleness trade-off. Full fix: orgId on discovery_state (deferred).
security3
?descubre URL param forwarded to API without client-side enum validation
apps/platform/src/components/discovery/FeatureSpotlight.tsx:52
Forwarded without safeParse. Server-side Zod rejects invalid values, so no data leakage. A client-side guard eliminates unnecessary round-trips.
NEXT_PUBLIC_VERCEL_ENV intentionally discloses deployment tier
apps/platform/next.config.ts
By design. No secret material. Comment in next.config.ts explains the tradeoff.
In-memory rate limit not shared across Lambda instances (pre-existing)
apps/platform/src/middleware
Pre-existing. recordOutcome endpoint callable in a tight loop across warm instances. Blast radius: inflated seenCount/dismissCount counters only.
conventions4
console.error used instead of createShellLogger in cross-domain shells
domains/cross-domain/src/feature-discovery.shells.ts:113
getDiscoverySpotlightShell and getDiscoveryGapShell use console.error. The project's structured logger (createShellLogger) is the canonical pattern for domain shells.
recordSpotlightOutcomeShell omits outbox event emission
domains/core/src/discovery/discovery.shells.ts:42
ADR-016: every state-mutating shell writes entity + outbox event atomically. This shell mutates discovery_state but emits no outbox event. If intentional (no subscribers yet), document as an ADR exception.
upsertOutcome deviates from canonical query naming convention
domains/core/src/discovery/discovery.queries.ts:46
Canonical naming: insert, updateWithVersion, findBy{Field}. The new upsertOutcome name is semantically accurate but does not follow the prescribed pattern.
Shell JSDoc still describes stale FCIS fetch-then-decide flow
domains/core/src/discovery/discovery.shells.ts:38
JSDoc comment on recordSpotlightOutcomeShell describes the removed fetch step (1. FETCH, 2. DECIDE, 3. EXECUTE). The implementation skips the fetch entirely.
tests7
isBudgetSpent product_tour exclusion untested
domains/cross-domain/src/__tests__/feature-discovery.decisions.test.ts
The isBudgetSpent filter that excludes product_tour rows has no test. A regression would block every spotlight for every user who has taken the tour.
getDiscoveryGapShell tourSeen logic has zero test coverage
domains/cross-domain/src/__tests__/feature-discovery.integration.test.ts
Both new behaviors in getDiscoveryGapShell (computing tourSeen from product_tour row, returning tourSeen:true for consumer orgs) are untested. A wrong value re-shows the tour every session or never shows it on first run.
GuidedTour state machine has no tests
apps/platform/src/components/discovery/GuidedTour.tsx
315-line component with non-trivial logic (openedRef guard, anchor retry loop, step navigation, forced re-launch, hazloAhora dispatch) has zero tests.
createSecret pending-deletion + tags combination path untested
packages/secrets/__tests__/unit/secrets-manager.createSecret.test.ts:77
Pending-deletion test has no tags variant. Source runs TagResourceCommand after Update when tags supplied. Compare: ResourceExists path has a tags variant.
resolveSpotlightPage and pathForPage have no unit tests
apps/platform/src/components/discovery/__tests__/spotlight-latch.test.ts
Pure functions with non-trivial edge cases (startsWith match, fallback path) called on every route change. The spotlight-latch test file only tests computeSpotlightLatch.
upsertOutcome dismissed path: lastSeenAt immutability not asserted in integration test
domains/cross-domain/src/__tests__/feature-discovery.integration.test.ts:161
The core pacing invariant (a dismiss must not spend the daily budget) — lastSeenAt unchanged after dismiss — is not asserted at the DB level in the integration test.
spotlight-latch forceAdopt idempotent case not pinned
apps/platform/src/components/discovery/__tests__/spotlight-latch.test.ts:63
forceAdopt=true with the SAME key as the shown key is a silent no-op (hits prev.key guard first). Correct behavior but not pinned — a guard reordering breaks it without a test failing.
improvement10
product_tour key hardcoded as string literal in cross-domain shell
domains/cross-domain/src/feature-discovery.shells.ts:154
TOUR_KEY is defined in tour-catalog.ts (frontend). Backend shell can't import it; string is duplicated. Should live in discovery-state.type.ts so renaming is caught at compile time.
DiscoveryTourSkippedEvent.properties typed as Record<string, never>
packages/analytics/src/events.ts
Rejects any property at compile time. Inconsistent with other tour events (started, completed, step_viewed) which carry contextual properties. Use properties?: { step?: string; stepIndex?: number }.
hazloAhora click-anchor path silently no-ops if anchor absent
apps/platform/src/components/discovery/GuidedTour.tsx:130
?.click() silently does nothing if the anchor isn't found. The anchor-retry loop emits discovery.spotlight_anchor_missing on miss; this path has no tripwire.
GuidedTour flagEnabled unsubscribe pattern inconsistent with siblings
apps/platform/src/components/discovery/GuidedTour.tsx:46
Returns posthog?.onFeatureFlags?.(cb) directly. DescubreMenu and FeatureSpotlight wrap it: () => unsubscribe?.(). Fragile if PostHog changes the return type.
Double-report when hazloAhora triggers
apps/platform/src/components/discovery/GuidedTour.tsx:124
onHazloAhora duplicates the report+track+setOpen sequence from close(). If close() gains cleanup logic, the hazloAhora path will silently miss it. Call close('completed') then apply the action.
cardPos useMemo reads window dimensions without resize dependency
apps/platform/src/components/discovery/GuidedTour.tsx:209
Resize listener updates rect but not window.innerWidth/innerHeight. On a vertical-only resize, placeBelow may be stale. Pre-existing in FeatureSpotlight; now in two card components.
Magic card-height estimate (190) inconsistent with FeatureSpotlight (180)
apps/platform/src/components/discovery/GuidedTour.tsx:214
190 may be too small for GuidedTour's taller layout (Hazlo ahora row + nav + skip). Should be a named constant; inconsistency with 180 should be documented.
tourSeen defaults to true on gap query failure
apps/platform/src/components/discovery/GuidedTour.tsx:70
Fail-safe and intentional (transient 500 suppresses first-run rather than re-showing on every error). Worth documenting in inline comment.
DescubreMenu navigates to TOUR_PAGE_PREFIX even when already there
apps/platform/src/components/discovery/DescubreMenu.tsx:155
Adds unnecessary history entry when already on /bills/contratos. router.replace or pathname check would avoid this.
CARD_WIDTH constants duplicated in GuidedTour (340) and FeatureSpotlight (320)
apps/platform/src/components/discovery/GuidedTour.tsx:31
Intentionally different values but no naming to signal the difference. Named constants (TOUR_CARD_WIDTH / SPOTLIGHT_CARD_WIDTH) would make the distinction explicit.
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:30current
- 46e32b2safeincremental0H · 0M · 3L2026-07-11 02:54
- 3b30949needs attentionincremental0H · 2M · 3L2026-07-11 02:39
- 9f8dbdfneeds attentionincremental3H · 5M · 5L2026-07-11 02:32
- 23191eeneeds attentionincremental5H · 12M · 7L2026-07-11 02:18
- fa9b88fneeds attentionincremental0H · 1M · 2L2026-07-11 01:30
- 6a8bf42blockedfull8H · 11M · 8L2026-07-11 01:08