> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cfosilvia.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Authenticated demo mode design

# 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](`#6-header-scoped-rls-isolation`) for full details.

```mermaid theme={null}
flowchart LR
  U["Authenticated Clerk user"] --> P["DemoModeProvider"]
  P --> I["PortfolioIdentity"]
  I -->|"normal"| R["Portfolio client: real JWT, no header"]
  I -->|"demo"| D["Portfolio client: real JWT + x-silvia-demo-mode: 1"]
  R --> S[("Supabase RLS")]
  D --> S
  S -->|"normal"| UD["Authenticated user's portfolio rows"]
  S -->|"demo header"| DD["Fixed shared demo rows"]
  P --> G["UI mutation guard"]
  G --> M["Middleware 423 backstop"]
```

### 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.

```mermaid theme={null}
sequenceDiagram
  participant User
  participant App as App
  participant API as /api/demo-mode
  participant Runtime as Keyed app runtime
  participant DB as Supabase RLS

  User->>App: Explore demo portfolio
  App->>API: POST enter (real Clerk session)
  API-->>App: Set 24-hour demo cookie
  App->>App: Block writes, refresh App Router payload
  App->>Runtime: Remount with real/demo key
  Runtime->>Runtime: Create fresh QueryClient, take over the guard
  App->>DB: Portfolio query + real JWT + demo header
  DB-->>App: Demo-only portfolio rows
```

### Public interfaces

```ts theme={null}
type DemoEntrySource = 'splash' | 'dashboard_cta' | 'sidebar'
type DemoExitSource = 'banner' | 'blocked_action' | 'chat_cta' | 'sidebar'
type DemoDestination = 'dashboard' | 'connect_account'

interface DemoModeContextValue {
  isDemo: boolean
  demoSessionId: string | null
  enterDemo(source: DemoEntrySource): Promise<void>
  exitDemo(source: DemoExitSource, destination?: DemoDestination): void
  blockAction(source: string): void
}

interface PortfolioIdentity {
  authenticatedClerkId: string | null
  portfolioClerkId: string | null
  isDemo: boolean
}
```

`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 POLICY`s 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:

| Request mode | Selectable rows | Insert/update/delete |
| - | - | - |
| No or invalid header | The authenticated user's existing rows; never demo rows | Existing own-row behavior, except demo rows remain permanently denied |
| Valid demo header | Only `clerk_id = DEMO_CLERK_ID`; never real rows | Denied |

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](#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.

```bash theme={null}
curl --request POST \
  --header "Authorization: Bearer $CRON_SECRET" \
  "$NEXT_PUBLIC_APP_URL/api/ops/demo-ai-cache/invalidate"
```

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](#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](#7-read-only-product-behavior),
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](#5-data-model-seeds-and-refresh)) —
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](#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.

| Event | Records |
| - | - |
| `demo_mode_chat_preview_viewed` | The chat preview rendered. `placement` is `page` or `floating_bubble`; once per session per placement |
| `demo_mode_chat_nudge_viewed` | The Ask Silvia coachmark opened |
| `demo_mode_chat_nudge_clicked` | The visitor followed it into the chat |
| `demo_mode_chat_nudge_dismissed` | The visitor closed it |
| `demo_mode_ai_prompt_abandoned` | A turn was left before it rendered. `aborted_during` is `request`, `replay`, or `polling` |

`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.

| Event | Records |
| - | - |
| `demo_mode_chat_cursor_played` | The demonstration started. `reduced_motion` records whether the travel was dropped |
| `demo_mode_chat_cursor_cancelled` | A visitor took over. `reason` is `pointer`, `key`, or `scroll`; `phase` is where it got to |

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:

| PR | Scope | Exit criteria |
| - | - | - |
| 1. Data foundation | Selective seed code, header-scoped RLS, migration tests, shared refresh service, cron, staging seed | Demo data is complete, fresh, demo-only under RLS, and immutable |
| 2. Runtime/read-only | Provider, portfolio identity and query migration, banner, guards, middleware, cookie QA path | No cache mixing; no user mutation path fires in demo |
| 3. AI previews | Prompt registry, shared cache/jobs, Chat/Research routes, Radar reconciliation and read endpoint | Cache hits and local fallbacks consume no AI tokens; visitor history remains isolated; Radar reconciliation is idempotent and notification flags stay off |
| 4. Experiment/UI | PostHog assignment, splash persistence, CTA, analytics, skills-repo update and deploy note | Test-only exposure is measurable and reversible |

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

| Area | Required proof |
| - | - |
| RLS | Missing/invalid header cannot read demo rows; demo header returns only demo rows even for unfiltered selects; real rows never union; all header/demo writes fail |
| Seed and cron | Deterministic, idempotent output; finite valuations; current dates; 400-day charts; exact table coverage; unauthorized cron is rejected |
| Identity and cache | Portfolio hooks use demo ID and distinct keys; profile/billing use real ID; enter/exit and cross-tab refresh/remount never show mixed data |
| Experiment | Control has no entry UI; test splash requires onboarding skip; seen state persists once per user; CTA remains while empty |
| Read-only | E2E coverage across dashboard, accounts, assets, transactions, settings, Vault, Radar, Research, subscription, and profile controls; no mutation request is sent |
| Chat and Research | Six server-owned registry prompts per surface, plus Research's 10 `suggested-*` sidebar prompts (see §8); raw/edited input stays local; hits never invoke generators; single-flight misses use the demo identity; outputs expire after 24 hours |
| Radar | `/api/demo/radar` requires an authorized demo session and returns only the three shared `DEMO_RADAR_DEFINITIONS`, matched by prompt and resolved to the newest schedule/result; reconciliation is idempotent, repairs config drift, disables stray/duplicate schedules, and bootstraps a schedule's first trigger at most once; every reconciled schedule stays `emailEnabled: false, smsEnabled: false`; UI mutation controls mount and route through the blocked-action dialog, and non-GET `/api/radar/*` routes independently 423 under the demo cookie |
| UI | Responsive Figma-aligned Chat, Research, and Radar previews omit low-data warnings; banner, splash, and dialog work in light/dark themes; semantic tokens only; local fallback supplied for every new `dynamic()` import |
| Release | Focused Vitest suites, `npm run test:ci`, lint, TypeScript check, production build, and staged-browser smoke test pass |

## 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](`#future-architecture-presentation-layer`),
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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.