Skip to main content

Authenticated Demo Mode — Engineering Design Abstract

Owner: @valmassoi · Created at: 2026-08-07

1. Summary

Authenticated Demo Mode lets an eligible, newly onboarded user explore a shared fictional portfolio without replacing their Clerk identity. The demo is a read-only product state, not an account type or a full impersonation mechanism, with a narrow exception for writes performed inside the isolated Python tool:
  • Portfolio reads resolve to one fixed demo identity (DEMO_CLERK_ID).
  • Profile, billing, Vault, Radar, Research, analytics, metering, and chat ownership continue to use the authenticated Clerk user.
  • Financial recurring-subscription reads follow the active portfolio identity; billing-plan subscriptions continue to use the authenticated Clerk user.
  • Mutations are blocked in the UI, browser data layer, and server middleware.
  • Demo Python may persist session artifacts and make mutating external requests; its session data is cleared every 24 hours.
Hypothesis: this feature will increase conversion of users linking their first account. It is exposed only to the test arm of the demo-mode-v01 PostHog experiment. It is primarily an onboarding escape hatch for users who skip connecting an account and have no financial data. The client resolves the experiment assignment silently, with send_event: false, and then requests demo-mode availability. Enrolled users call GET /api/demo-mode in both arms: the arms must run the same eligibility check, or the control has no counterpart to the treatment users the splash reaches. Users the flag never enrolled do not call it, which is what limits the endpoint’s rollout blast radius. The GET is side-effect free; only the enter and decline mutations consume a splash impression. Treatment users remain subject to the endpoint’s portfolio and splash eligibility checks, and the enter mutation rechecks portfolio eligibility on the server.

Experiment exposure

The experiment counts a user as exposed on the custom demo_mode_experiment_exposed event, not on flag resolution. Resolving demo-mode-v01 says nothing about whether the user could ever have seen the splash, so a default $feature_flag_called exposure would enrol every signed-in user in the app and bury the effect under users with a portfolio and users who never skipped onboarding. useDemoModeExposure emits the event once the server confirms both noPortfolioData and onboardingSkipped, carrying $feature/demo-mode-v01 (which PostHog reads the assignment from) plus $feature_flag and $feature_flag_response. The experiment’s exposure criteria in PostHog must point at this event for the analysis to match the code. Exposure deliberately stops short of the third leg of demoSplashEligible, the splashImpressions < DEMO_SPLASH_DISPLAY_LIMIT check. Only the treatment arm ever shows a splash, so only it increments that counter, and folding the limit into exposure would retire test users from the experiment after three splashes while control stayed exposable indefinitely. Both arms now enter the analysis on the same condition and stay exposable for the same length of time. Two things have to hold for that event to fire at all. Eligibility is not read while the user is still inside setup, because the answer would predate the connect-account skip that eligibility depends on. And the availability query opts out of the app-wide five-minute staleTime, because the skip happens mid-session and a cached pre-skip answer would otherwise be replayed as fresh for the whole first dashboard visit.

Cohort cutoff

Enrolment is limited to users created at or after DEMO_MODE_EXPERIMENT_LAUNCH_AT_MS (2026-09-09 19:00 UTC), checked against Clerk’s user.createdAt before the flag is read. The cutoff belongs in code rather than in a signup_date is_date_after release condition on the flag: signup_date is written by the person-property sync well after signup, so it is absent for almost every user during the first session, which is the only session in which the connect-account skip this experiment measures can happen. A flag condition on it resolved false for 59 of the first 60 users who reached that skip, and false is indistinguishable from “not bucketed”, so those users left the experiment silently rather than visibly failing. Clerk’s createdAt is available in the first render and needs no sync, so the flag is left carrying only its rollout percentage. That also keeps false unambiguous: with no person-property condition, it can only mean the user fell outside the rollout bucket, which is a final answer rather than one that may change once a sync lands.

2. Goals and non-goals

Goals

  • Make a realistic, maintained sample portfolio available to eligible users.
  • Keep the user’s real authentication, entitlement, and ownership boundaries intact throughout the demo session.
  • Guarantee that a demo session can never write user data or demo data, except for the Python tool.
  • Prevent query-cache (React Query) or RLS (Supabase) mistakes from mixing real and demo portfolio data.
  • Give demo chat accurate answers about the fictitious household while storing its usage and ownership against the real user.
  • Support an incremental, independently releasable rollout.

Non-goals

  • Creating a per-user demo portfolio or copying sample data into a user-owned account.
  • Impersonating a demo Clerk user or changing useUser() globally.
  • Seeding provider-backed accounts, credentials, connections, or syncable records (Plaid, SnapTrade, Coinbase, etc.).
  • Giving demo mode access to normally mutating product features.
  • Treating the demo portfolio as private data. It is intentionally fictional and readable by authenticated users that opt into the supported demo header.

3. Eligibility and experiment assignment

Demo mode is available for the user if:

  • No entries in: accounts, manual_accounts, manual_public_holdings, crypto_holdings, properties, …
  • Is in the PostHog experiment: demo-mode-v01

The splash model has additional requirements:

  • Onboarding was skipped, determined by connect_account_skipped cookie [existing]
  • Has recorded fewer than DEMO_SPLASH_DISPLAY_LIMIT splash decisions on their user [new]
The persistent Demo switch (desktop sidebar and mobile drawer footer) remains available while the real portfolio is empty and stays visible while demo mode is active so the user can exit. Acting on the splash, either by entering demo mode or dismissing it, suppresses the splash for the remainder of the current browser-tab session. This prevents it from immediately reopening after a mode transition remount without consuming another persisted splash impression. all_users.demo_splash_impressions integer not null default 0 retires the splash after DEMO_SPLASH_DISPLAY_LIMIT recorded decisions, rather than once per user or once per browser. The splash takes precedence over other dashboard popups.

4. Architecture

A x-silvia-demo-mode header is added to Supabase queries as a branch for what data to read: Real or Demo. See §6 for full details.

Future architecture: presentation layer

Today, Silvia Web reads portfolio tables directly through Supabase clients. That keeps the implementation close to the data model, but makes each client hook have responsibilities that could be abstracted. As the application grows, introduce a new presentation-service between the web application and Supabase. This service would expose typed, UI-ready portfolio read models (for example, dashboard summary, account list, holdings, and chart series) rather than letting page hooks query tables directly. It also allows for code reuse for the mobile application. For this demo feature, it would receive the authenticated identity and a trusted demo-session signal, resolve the effective portfolio identity in one place, and issue the underlying Supabase reads. Demo mode then becomes an explicit service concern

Session state

silvia_demo is a versioned, HTTP-only cookie with a 24-hour lifetime. The signed-in app layout reads it with asynchronous cookies() and supplies the initial value to DemoModeProvider, avoiding a real-data flash on first render. Entering and exiting are server-owned cookie transitions; the browser does not manufacture the active state locally. Mode transitions refresh the current App Router payload after the server-owned cookie changes. The signed-in provider subtree is keyed by authenticated user and effective portfolio mode, so the refreshed payload remounts every app context and creates a new React Query client before rendering the new portfolio surface. Redirecting exits navigate and refresh: /connect-account sits under the same (app) layout that owns the runtime key, so partial rendering would reuse the cached layout segment and leave the tab in the mode it just left. The same transition is used for banner exit, blocked-action exit, sidebar exit, and chat exit. The refresh keeps the tab interactive for a server round trip, so the browser write guard is split across that window. demoModeRuntimeActive tracks the runtime that is currently mounted and decides which rows reads ask for; DemoModeProvider is its only writer, from its mount/unmount layout effect. demoModeTransitionPending records whether the cookie’s incoming mode differs from the mounted runtime (in the acting tab and, over the transition channel, in every other tab) and blocks writes until the keyed remount hands the guard back. Reads are never flipped ahead of the remount: doing so would return the incoming runtime’s rows to the outgoing runtime’s UI.

Public interfaces

usePortfolioIdentity() is the only supported way for portfolio-facing hooks to obtain a user ID. authenticatedClerkId always returns the real Clerk ID; portfolioClerkId is DEMO_CLERK_ID only while demo mode is active. No non-portfolio surface may use portfolioClerkId. Every user-scoped portfolio query key uses portfolioClerkId. This both separates caches and preserves the repository’s cache-key convention. Profile, billing, and other non-portfolio keys retain the authenticated ID.

5. Data model, seeds, and refresh

The shared Demo User portfolio is manual-only. It contains fictional bank and brokerage accounts, public holdings, crypto, property and mortgage, vehicle, private investment, precious metals, transactions, and 400 days of history. Every demo row uses the fixed DEMO_CLERK_ID; the accompanying all_users row is a test user with a .invalid email, is_test_user = true, and every outbound preference disabled. The seed and refresh services share deterministic identifiers, fixtures, and write helpers. The CLI wrapper (npx tsx scripts/seed-demo-user.ts) uses the one-off service that can replace all frozen history; the nightly route uses an additive service that upserts the current portfolio, appends missing snapshot days, and only then prunes the oldest days. Its append cursor is the oldest latest date across the summary and every detail table expected to contain rows, so an interrupted seed or refresh is repaired rather than hidden by a newer summary row. No package script is added. The refresh preserves finite values and exactly 400 days of history after a successful run. Manual-account balances use a calendar-stable model shared by the live account fixtures and their daily snapshots. Fidelity brokerage and retirement accounts share broad market movement with small account-specific variation; checking and savings follow lower-volatility cash-flow cycles; card balances stay within bounded statement cycles; and mortgage amortization proceeds forward and backward from a fixed reference balance. Its origination and maturity dates remain fixed loan metadata. Summary cash, investment-account, and debt buckets are derived from those same per-account values, so the accounts chart and net-worth history cannot disagree. Transaction fixtures use the exact shared system-category names present in production. Seed and refresh fail before portfolio writes when any required category is missing or has no group UUID; silently seeding uncategorized rows would also make the transaction menu’s category-group filter a no-op. Both paths prune manual transactions that have aged beyond the rolling fixture. The ledger includes three deterministic monthly App Subscriptions merchants across the existing demo credit cards, giving the recurring-spend surface enough history without creating provider items or accounts. In demo mode, the shared subscription hook derives cards and totals from those manual rows, and its transaction dialog reads the matching merchant history. Ordinary portfolios retain the provider-backed subscriptions query. Provider item sync polling is disabled while the demo portfolio is active. There are two intentionally distinct table contracts:
  • DEMO_SEEDED_TABLES is the complete seed/teardown contract, including all_users where required by foreign keys.
  • DEMO_PORTFOLIO_TABLES is the subset exposed to the browser portfolio client. It excludes identity/profile tables, especially all_users.
Migration, seed, teardown, and fixture tests must assert these contracts together. Adding a table requires an ownership audit, seed fixture, refresh behavior, teardown behavior, RLS policy, and browser-read decision.

Nightly refresh

GET /api/cron/refresh-demo-portfolio is protected by CRON_SECRET and scheduled with Vercel Cron. It returns a non-2xx response on authorization, seed, or verification failure; errors are structured and alertable. The route uses the server-side service-role client only and is never reachable from the browser data path.

6. Header-scoped RLS isolation

The browser uses a Supabase client constructed with the real Clerk access token. It will now add a x-silvia-demo-mode: 1 only for demo portfolio reads. A db migration will extract the x-silvia-demo-mode flag from the request.headers JSONB object, which Supabase automatically populates from incoming HTTP requests. And CREATE POLICYs for a list of portfolio tables (properties, crypto_holdings, …) to allow the demo reads. For each browser-exposed demo portfolio table, retain the existing own-row permissive policy and add the following policy semantics: This requires both policy kinds. A permissive select policy admits the demo row when the header is valid; a restrictive select policy scopes all permissive policies to demo-only data in header mode and non-demo data without the header. Restrictive write policies reject any request carrying the header and reject writes targeting demo rows. Thus an unfiltered select in demo mode cannot union real and sample rows, and neither a real user nor a demo-mode client can mutate the shared dataset. The header is an intentional access switch for non-sensitive data, not proof of experiment eligibility. Eligibility and PostHog determine who sees product entry points; RLS determines which rows a request can see.

7. Read-only product behavior

Demo mode leaves the signed-in app navigable. Portfolio pages render sample data; all non-portfolio pages display the authenticated user’s real data. The banner explains that the user is viewing sample data, with an Exit demo action on mobile. On Profile and Settings, it instead warns: “Exit to update your real account data” The empty real dashboard shows an Explore a demo secondary CTA when eligible and assigned to test. Eligible users also see a persistent Demo switch in the sidebar (and in the mobile drawer’s footer on small viewports); the switch stays checked and available as an exit control during demo mode. useDemoReadonly() is the UI choke point for state-changing actions. Blocked controls remain discoverable but route through one explanation dialog rather than opening a mutation flow. It covers add, edit, delete, connect, import, customize, billing, profile, Vault, Radar, and Research actions, as well as background persistence and sync effects. Three layers enforce the same invariant:
  1. UI guards prevent portfolio mutation flows and background effects.
  2. A demo-aware browser fetch wrapper rejects unsafe Supabase mutations before dispatch.
  3. Middleware returns 423 { error: 'demo_read_only' } for unsafe /api methods while the demo cookie is present. Exact-path exceptions cover demo runtime endpoints.
Allowed behavior is navigation, sign-out, theme/privacy display preferences, PostHog telemetry, and the cached Chat/Research previews. “Connect account” clears demo state, navigates to the real dashboard, and consumes a one-time dashboard intent parameter to open the existing add-account chooser. All new styling uses existing semantic theme tokens and must be checked in light, control-dark, and dark-variant-B modes. The banner, splash, CTA, and dialog require a companion update to CFO-Silvia/skills before release.

8. Demo Chat, Research, and Radar previews

Chat and Research use a versioned, typed server registry with six curated prompts per surface (DEMO_PROMPTS.chat, DEMO_PROMPTS.research in lib/demo/ai-prompts.ts). A browser submits only { promptId }; display text and generation text are canonical server values and cross-surface or unknown IDs are rejected. Preview messages stay tab-local and are never copied into the visitor’s Chat or Research history. Research additionally accepts prompt IDs from its Suggested Research sidebar — see Research suggested-idea prompts below. Successful output is stored in Redis for exactly 86,400 seconds. Keys include the cache schema, prompt-registry version, demo-portfolio cache generation, surface, and prompt ID. Atomic claims provide single-flight generation: one miss runs the normal engine and other visitors poll. Successful portfolio refreshes atomically advance the generation; an invalidation failure fails the cron and is reported to Sentry. Old generations expire naturally.

Daily cache warming

GET /api/cron/warm-demo-ai-cache (CRON_SECRET, Vercel Cron, 05:20 UTC) queues a generation run for every demo Chat and Research prompt that is not already cached. A prompt reads as a cache hit only once its warm run has generated successfully, so a visitor who arrives while a run is still in flight takes the normal cold path. It runs five minutes after the 05:15 portfolio refresh, whose maxDuration is 120 seconds, so the generation it reads is always the post-refresh one. Only the twelve prompts the previews offer up front are warmed; the Suggested Research sidebar ideas stay on the cold visitor path. The scheduler only queues. The two surfaces are walked concurrently and each is staggered on its own clock, so neither waits on the other, and nothing is ever retried by QStash — the prompt’s own failure budget or attempt count owns that decision. Its own maxDuration is 300 seconds: it makes about thirty sequential Upstash and QStash calls before it is done, and a run killed part way through queues nothing for the prompts it never reached and cannot report which ones those were. The scheduler requires NEXT_PUBLIC_APP_URL: QStash refuses a loopback or private destination, so there is no useful fallback and the run returns 500 rather than queueing work that cannot be delivered. A publish that QStash rejects is recorded per prompt in that surface’s failed list and the remaining prompts are still queued. Chat prompts are generated by POST /api/cron/warm-demo-ai-cache/process-prompt, a QStash worker with the same 800-second ceiling as the visitor route (the claim and job TTLs are derived from it), staggered 60 seconds apart. Both ends refuse to spend tokens on work that cannot be read: the scheduler skips prompts that are already cached or have spent their failure budget, and the worker re-checks all of that plus the cache generation, which can advance between queueing and execution. Generation itself goes through generateAndCacheDemoChat in lib/demo/generate-demo-chat.ts, the same claim, job and failure-budget path a cold visitor request takes, so a visitor who arrives mid-warm polls the running turn instead of starting a second one. Research prompts are warmed by POST /api/cron/warm-demo-ai-cache/process-research, staggered 120 seconds apart. A Deep Research run takes 20-40 minutes, far longer than any invocation, and no browser is open to poll it, so the worker takes one step per invocation and re-queues itself: whenever a step opens a session it waits 10 minutes, and every other step polls again after 3 minutes, for at most 20 polls (about 67 minutes, or 74 when a second attempt opens a new session, both inside the job’s two-hour record). Each step goes through advanceDemoResearch in lib/demo/generate-demo-research.ts, the same job and retry path a cold visitor request takes, so a visitor arriving mid-warm polls the same session rather than opening a second one — and whichever of them sees the finished session first collects the report into the cache. Giving up on the poll chain does not cancel the run: its job record lives for two hours, so a later visitor can still poll it to completion. The cap is checked before that call rather than after it, because advanceDemoResearch does not only report — it opens a replacement attempt when no job is live, and spends one when the session is dead, neither of which a capped invocation is willing to start or collect. Research spends no tokens in this process and caps its own retries through the job’s attempt count, so it has no failure budget for the scheduler to check; it skips only prompts that are already cached.

Manual AI cache invalidation

Operations can invalidate the cached demo Chat and Research previews without refreshing the demo portfolio by calling the protected endpoint below. It uses the same CRON_SECRET authorization as the portfolio-refresh cron and advances the shared cache generation, so prior results and in-flight Research jobs are immediately ignored while their Redis keys expire naturally.
The successful response includes the new cacheGeneration. This is an on-demand operations endpoint, not a scheduled cron route. On a Chat miss, the existing engine and every executor run directly under DEMO_CLERK_ID. That identity is the single server-side demo signal; do not add or thread a parallel isDemoMode field through ToolContext. SQL infers the demo RLS header from this identity, while Python uses the same standard timeout and execution path as normal Chat. The demo Chat allowlist is intentionally limited to get_current_time, skills, execute_sql, and execute_python. Python may be used for read-only calculations, live market data, and web research. Unlike other demo tools, Python may also persist artifacts in its isolated session storage and make mutating external requests; persisted demo Python data is cleared every 24 hours. The allowlist is enforced both when tools are advertised to the model and again at executor dispatch. Do not expand it without a registered prompt that needs the added capability and a read/write isolation review. In particular, read_tax_authority is unnecessary for the current six prompts, quant_analyst adds another model/tool loop, Trove and image tools have no demo inputs, and the remaining tools mutate state or communicate externally. Visitor token gates are bypassed, usage remains observable under the demo identity, and Customer.io/email/upload/mutation paths are unavailable. On a Research miss, one shared Deep Research session starts with clerk_id: DEMO_CLERK_ID; the preview endpoint (or the warm worker, when it got there first) polls its progress and caches the completed Markdown without exposing that session as visitor-owned history. Chat only caches output after the turn completes without a terminal stream error or a required portfolio-tool error, and the persisted response must pass the failed-markdown guard. A failed optional Python lookup does not discard a valid completed answer: the model falls back to seeded values and their price_as_of dates. Known failed Markdown found in an older result entry is evicted and regenerated; failed responses are marked no-store. Each claimed regeneration uses a fresh run ID so a prior completed-but-invalid database row cannot be replayed as the new attempt. Research generation is bounded to three shared attempts. Each attempt uses a distinct session ID and must create its session row within 30 seconds. There is no execution deadline once it does: a run takes 20-40 minutes, so a session that is still progressing is polled for as long as it takes. Redis job state coordinates attempt ownership and briefly retains terminal status so every active poller stops. A later run receives fresh session IDs. Only successful Markdown is written to the 24-hour result cache; failed responses are marked no-store. Both inputs remain enabled. Chat and Research prompt buttons both execute immediately by prompt ID and clear the composer, so a cached preview never depends on the text a visitor sees in the input. Typed input is handled locally, without an AI request, using the release-copy fallback and a Connect Account action that exits demo mode to /connect-account. The empty Chat and Research views follow the standard page shell: centered title, large composer, helper copy, and a two-column grid of six prompts. Once submitted, the prompt grid gives way to the normal conversation/report rhythm and the compact composer. The desktop Chat History and Research History cards are fictional, static preview fixtures only; they never load or mutate visitor history. The Research surface’s Suggested Research card is not one of those static fixtures. It is the same live ResearchIdeas component real users see, intentionally reused so the demo showcases the real idea generator; see Research suggested-idea prompts below. The low-data warning is intentionally omitted from all Demo Mode variants.

Research suggested-idea prompts

Research also accepts the 10 prompt IDs behind its Suggested Research sidebar card (components/research-ideas.tsx, the same component real users see). DEMO_RESEARCH_IDEA_PROMPTS in lib/demo/ai-prompts.ts maps each of DEMO_RESEARCH_IDEAS (lib/research-ideas.ts) to a suggested-* prompt ID, seeded with a fixed DEMO_RESEARCH_NET_WORTH; getDemoPrompt() accepts these alongside the six registered research prompts, and a click runs the identical /api/demo/research path — same cache, single-flight, and DEMO_CLERK_ID protections. The wording is existing, already-approved product copy reused as-is, not new demo copy.

Demo Radar

Radar is a live read against the same external Radar API (RADAR_API_URL, an AWS Lambda service) that serves real users, not a static preview. There is no per-visitor Radar identity: lib/demo/radar-service.ts authenticates every request with X-User-Id: DEMO_CLERK_ID and a server-only RADAR_API_SECRET, so every demo visitor, across every browser and session, observes and can trigger the same three shared schedules. This mirrors the shared-identity model already used for demo Chat and Research above, rather than the per-row RLS isolation used for the portfolio. GET /api/demo/radar is the only Radar surface a visitor’s browser calls. It is gated by authorizeDemoRequest() (a real Clerk session plus that user’s own valid demo cookie) and returns a read-only projection: getDemoRadarResponse() lists the shared account’s schedules and results, matches each of the three DEMO_RADAR_DEFINITIONS by exact prompt text, and picks the newest schedule and its newest result. It never creates, edits, or triggers a schedule. useDemoRadars() polls this endpoint every 60 seconds while the demo Radar page is mounted, so the feed reflects the shared schedules’ real state (including PROCESSING) without a reload. Radar’s UI is not stripped down to static cards. DemoRadarPreview renders the same feed shape, filter header, and RadarSchedulesSidebar used for real Radars, populated from the live /api/demo/radar payload. Every state-changing control, including create, edit, delete, toggle, manual trigger, mark-read, like/dislike, bookmark, and “Discuss with Silvia”, mounts and is clickable; each one calls onBlockedAction/useDemoReadonly and opens the standard demo explanation dialog instead of dispatching a request. This is the same UI-guard layer described in §7, not a Radar-specific mechanism. Because the demo cookie also gates unsafe HTTP methods in middleware (§7 layer 3), a request to /api/radar/schedules, /api/radar/trigger, or any other Radar write route independently 423s for a demo session even if a UI guard were ever bypassed. Those routes are intentionally absent from DEMO_MODE_WRITE_ALLOWLIST. Scheduler and reconciliation. The three shared schedules are owned and kept in sync by reconcileDemoRadars(), called once per night from GET /api/cron/refresh-demo-portfolio (CRON_SECRET-protected, Vercel Cron) after the portfolio roll-forward and AI cache generation bump, so each scheduled run analyzes refreshed balances, transactions, and history. For each of the three DEMO_RADAR_DEFINITIONS it: creates the schedule if no schedule with a matching promptTemplate exists; repairs config drift (enabled, frequency, timeUtc/timeLocal, timezone, emailEnabled, smsEnabled) on the newest matching schedule; disables older duplicates of the same prompt and any enabled schedule whose prompt is not one of the three registered ones; and triggers a schedule exactly once, only if it has never produced a result, to bootstrap first content. The pass is idempotent: a run against fully reconciled state performs zero writes — and is the only code path allowed to mutate the shared schedules. Notification protections. Every schedule the reconciler creates or repairs is forced to emailEnabled: false, smsEnabled: false; drift in either flag is corrected on the next nightly pass regardless of cause. This is layered on top of, not a substitute for, the demo all_users row having every outbound preference disabled (§5) — either guard alone is sufficient to prevent the shared demo account from paging or emailing a real inbox, and both hold independently so a Radar schedule can run unattended overnight. Deployment requirements. RADAR_API_SECRET must be configured in every environment that reconciles or reads demo Radars; both getDemoRadarResponse() and reconcileDemoRadars() throw immediately if it is missing. RADAR_API_URL (or NEXT_PUBLIC_RADAR_API_URL) selects the Radar API host and falls back to the production Lambda URL if unset. Non-production reconciliation passes workerEnv: 'staging' on create/trigger calls (gated on VERCEL_ENV/NODE_ENV !== 'production') so staging schedules never fire against the production worker pool. CRON_SECRET authorizes the nightly reconciliation route the same way it authorizes the portfolio refresh. The 12 registry strings (six Chat, six Research) are product-approved. Seasonal prompts must never be substituted silently. Any future change to the wording goes back through product approval. The ten Research suggested-idea prompts (see Research suggested-idea prompts above) reuse existing approved product copy as-is.

9. Observability and rollout

Record non-sensitive events for experiment exposure, splash display/dismissal, demo enter/exit (with source/destination), blocked action source, and CTA use. Do not emit sample-account identifiers, balances, prompts, or other portfolio contents to analytics. Log structured server events for RLS/header rejects, middleware 423s, cron runs, and seed verification failures.

The chat preview funnel

The events above measure entry into demo mode but not what happens inside it, and the first ten days after launch showed why that matters: of 51 demo sessions, 20 reached /chat, 11 submitted a prompt, and 4 saw a finished answer. The section above did not call for a chat-surface impression event, so the denominator was missing and refusal could not be told from abandonment. demo_mode_ai_prompt_resolved also carries input_type, cache_status, and waited_ms. Free text resolves with result_status: 'fallback' — it previously submitted and never resolved, which made every typed question read as a failed turn. waited_ms is what makes the artificial cache-hit replay delay measurable against completion rather than assumed to be free. demo_mode_ai_prompt_abandoned fires on unmount, so posthog-js can still drop it when a tab is closed outright; it is reliable for client-side navigation only for the request, replay, and polling turns driven by suggested prompts. Free-text submissions have no matching emission: addFallback clears its pending timeout on unmount but never captures an abandoned event, so navigating away or closing the tab during the fallback delay is invisible to this event today. Treat demo_mode_ai_prompt_abandoned counts as undercounting free-text abandonment until a fallback emission is added. The cursor demonstration on the chat page submits under input_type: 'autoplay', never suggested. It runs for every eligible first arrival, so counting it as a chosen prompt would put the submission rate at roughly 100% and destroy the measurement above. Read suggested and freeform for visitor intent; read autoplay only to check the demonstration itself is firing. The chat nudge is deliberately not a nested experiment. Demo mode is already the test arm of demo-mode-v01, and at roughly 50 sessions per ten days a second split leaves no readable cell. Compare the funnel before and after instead. Delivery is split to keep the security boundary reviewable: Production starts at control. Enable the test arm only after staging data, automated gates, and staged-browser smoke tests pass. The PostHog flag is the product-exposure kill switch; a user already in demo remains active until exit or cookie expiry.

10. Verification matrix

11. Alternatives considered

Authenticate the browser as the demo user with a Supabase JWT

Rejected. The browser could exchange its real session for a Supabase JWT whose subject is DEMO_CLERK_ID, then use the existing own-row RLS policies unchanged. This would make the data switch superficially simple, but it turns the session into an impersonation boundary. Any code using the current Supabase identity could accidentally attribute chat, metering, analytics, or other user-owned behavior to the demo user. It would also require securely minting and rotating a second identity token in the browser flow. The product requirement is the inverse: retain the real authenticated identity everywhere and substitute only the portfolio being read. The selected design therefore keeps the real Clerk-derived JWT and makes the demo-data choice explicit and limited to the dedicated portfolio read client (or, later, the presentation layer).

Add a permissive RLS grant for demo rows

Rejected. A policy such as “any authenticated user may select rows where clerk_id = DEMO_CLERK_ID” is insufficient on its own. Existing own-row RLS policies would remain permissive, so an unfiltered query could return the authenticated user’s real rows and demo rows. This makes every historical and future query against a demo-seeded table security-sensitive. Making this safe would require a broad migration to audit and update every existing portfolio query with an explicit clerk_id filter, enforce that rule for all new queries, and maintain linting/tests to catch regressions. That is a high-risk, unrelated rewrite of a large read surface, and it still leaves the demo/real selection spread across clients. The selected header-scoped RLS design avoids that migration. Its restrictive policy changes the visible dataset for the request: with the demo header, even an unfiltered query can see only demo rows; without it, demo rows are invisible. Existing queries retain their normal behavior, while new demo-mode portfolio reads only need the dedicated client/context boundary.

Move all portfolio reads behind a presentation layer before shipping

Deferred, not rejected. A presentation layer is the preferred long-term boundary described in §4, but making it a prerequisite would expand this feature into a full migration. The first delivery uses a dedicated client and header-scoped RLS to preserve the same core security properties. The new service can later absorb the demo identity-resolution logic incrementally, starting with the most complex dashboard read models.

12. Risks and decisions

  • Header discoverability: Any authenticated user can manually request the fictional dataset. This is accepted because the portfolio has no sensitive data; it must never become a path to real-user data.
  • RLS drift: A newly seeded table can create an exposure or an incomplete portfolio. Contract tests pin seed, refresh, teardown, and RLS lists.
  • Cache contamination: A stale response can look plausible. The portfolio identity is included in every key and mode transitions hard-clear caches.
  • Read-only bypass: UI controls alone are insufficient. The browser wrapper and middleware remain mandatory independent backstops.
  • Real-data change during demo: The demo remains active until explicit exit or 24-hour expiry. A change to the real account in another browser does not alter the active demo session.