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

# PER DR OG CARDS

# Per-deep-research Open Graph preview cards

**Status:** Shipped Sep 2026 (staging)
**Companion doc:** [PER-RADAR-OG-CARDS.md](../radar/PER-RADAR-OG-CARDS.md) — same architecture applied to the /radar surface

## What this changes

Before this change, every deep-research (DR) completion URL Silvia
sent over Linq / SMS unfurled with the **site-wide generic Silvia OG
card** — same image, same title ("Research — In-depth research
reports…"), same description for every completion. iMessage bubbles
for DR completions felt beige and utility-grade; the user couldn't
distinguish "your Nvidia stress test is done" from any other Silvia
link until they tapped through.

After this change, every DR completion URL (bubble 2 in
`app/api/sms/research-complete/route.ts` — the bare
`https://www.cfosilvia.com/research?session=<id>&utm_source=sms` URL)
renders with a **DR-specific** preview card:

* **Title** = the user's DR question, sanitized + truncated to ≤80 chars
  (e.g. "Stress test my portfolio against a 2008-style crash.")
* **Description** = a data-dense meta line — duration + source count
  (e.g. "27 min · 199 sources analyzed — tap for the full analysis.")
* **Image** = a 1200×630 PNG rendered on-demand at
  `/api/og/research/[sessionId]` showing the Silvia mark, a
  `DEEP RESEARCH` pill, the sanitized question, the meta line,
  the completion date, and a `View full report →` CTA.

Third-party clients that request the URL (iMessage, Slack, Discord,
Facebook, Twitter/X, LinkedIn) all render the per-DR preview from
these tags automatically — Silvia does not send them separately.

## Architecture

```
Unfurler                                                    Vercel
   │                                                          │
   │ GET /research?session=<id>                               │
   ├───────────────────────────────────────────────────────>  │
   │                                                          │
   │                                                    ┌─────┴──────┐
   │                                                    │ middleware │  bypasses sign-in
   │                                                    │ (unfurl-   │  redirect for known
   │                                                    │  bots      │  crawler UAs
   │                                                    │  helper)   │  (already existed)
   │                                                    └─────┬──────┘
   │                                                          │
   │                                                    ┌─────┴──────────────────┐
   │                                                    │ app/(app)/research/    │
   │                                                    │ page.tsx (server)      │
   │                                                    │  generateMetadata      │
   │                                                    └─────┬──────────────────┘
   │                                                          │
   │                                                    ┌─────┴─────────────────────┐
   │                                                    │ generate-research-        │
   │                                                    │ metadata.ts               │
   │                                                    │  buildResearchPage-       │
   │                                                    │  Metadata()               │
   │                                                    └─────┬─────────────────────┘
   │                                                          │
   │                                                    ┌─────┴───────────────────┐
   │                                                    │ lib/research/           │
   │                                                    │ og-fetch.ts             │
   │                                                    │  fetchResearchForOg()   │  ← service-role
   │                                                    └─────┬───────────────────┘    Supabase lookup
   │                                                          │                        (safe subset only)
   │  HTML with per-DR OG tags                                │
   │  <meta property="og:image" content=".../api/og/research/X">
   │ <────────────────────────────────────────────────────────┤
   │                                                          │
   │ GET /api/og/research/[id]                                │
   ├───────────────────────────────────────────────────> ┌────┴──────────────┐
   │                                                     │ route.tsx         │
   │                                                     │ (edge runtime)    │
   │                                                     │  next/og          │
   │                                                     │  ImageResponse    │
   │                                                     └────┬──────────────┘
   │  1200×630 PNG + Cache-Control                            │
   │ <────────────────────────────────────────────────────────┤
```

## Files

| File | Role |
| - | - |
| `app/(app)/research/page.tsx` | Server-component wrapper. Exports `generateMetadata`. Renders `<ResearchPageClient />`. New. |
| `app/(app)/research/research-page-client.tsx` | The existing `'use client'` research UI, unchanged. Renamed from the old `page.tsx`. |
| `app/(app)/research/generate-research-metadata.ts` | Builds `Metadata` from `searchParams.session`. Sanitizes the question, truncates safely, formats the duration + source-count meta line. Falls back to generic Research metadata on every failure branch. New. Exports `buildResearchMetaLine` for the OG route to share the same body text. |
| `app/api/og/research/[sessionId]/route.tsx` | Edge-runtime OG image endpoint. Renders 1200×630 PNG via `next/og`. Same-origin 302-redirects to `/opengraph-image` (with `Cache-Control: no-store`) on all failure branches — never 404s, so iMessage's LP framework never surfaces a broken image and the single-`og:image` posture is preserved. New. |
| `lib/research/og-fetch.ts` | Shared safe-subset Supabase lookup (`research_sessions` + `dr_sms_notifications`) + session\_id shape validator. New. |
| `app/(app)/research/page.stories.tsx` | Storybook file, updated to import the client shell directly (Storybook can't render server components). |

## Failure modes (fail-closed at every layer)

Every non-happy branch **redirects to the site-wide generic Silvia
OG card** (`/opengraph-image`) — same visual experience as before
this change:

| Trigger | Result |
| - | - |
| No `?session=` query param | Generic Research OG (metadata builder returns fallback) |
| Malformed session\_id | Metadata builder returns generic; if the URL is fetched anyway, route 302s to `/opengraph-image` |
| Research row missing | Metadata builder returns generic; route 302s to `/opengraph-image` |
| Status ≠ `completed` (running / failed / cancelled) | Metadata builder returns generic; route 302s to `/opengraph-image` (delivery gate) |
| No SMS handoff AND completed >2h ago (webapp-only DR) | Metadata builder returns generic; route 302s to `/opengraph-image` |
| SMS handoff exists but `sent_at IS NULL` AND completed >2h ago | Generic Research OG (grace expired, strict SMS-proof re-asserts) |
| `completed_at` corrupt (>10 min in the future) | Generic Research OG (clock-skew bound) |
| Supabase env unconfigured | Generic Research OG (route 302s to `/opengraph-image`) |
| Supabase error / timeout on either table | Generic Research OG (route 302s to `/opengraph-image`) |
| `/api/og/research/[id]` fetch fails after metadata already emitted | Same 302 to `/opengraph-image` — crawler follows the redirect and lands on the site-wide Silvia card |

**Correctness contract:** the URL bubble is never worse than
today. Broken data is a preview downgrade, never a broken image,
and always a single card (never a stacked double-render).

### Why the route redirects instead of 404-ing on failure

The radar OG's first ship (Sep 12) 404'd on every failure branch
and listed `/opengraph-image` as `openGraph.images[1]` for defence
against that 404. Real-world iMessage testing showed iMessage's
Link Presentation framework STACKS every `og:image` entry into a
separate preview card, so the fallback rendered as a SECOND card
every time — a double-card unfurl instead of a fallback. That fix
was landed on the radar OG and this DR OG ships with the corrected
posture from day one: `openGraph.images` carries exactly one entry,
the route redirects 302 to `/opengraph-image` on every failure
branch. Result: iMessage renders exactly one card, always.

## Security / safe-subset policy

The OG endpoint is **unauth-callable** (iMessage's unfurler is
anonymous), so anyone who knows / guesses a `session_id` can
attempt to mint a Silvia-branded card for that DR. DR content is
**materially more sensitive than radar content** — DR questions
can contain SSN benefits, dates of birth, dollar-figure planning
inputs, spouse names, and other financial PII (verified against
the prod corpus, Sept 2026). This surface is therefore stricter
than its radar sibling in two dimensions:

1. **Field-level whitelist.** Only `session_id`, `question`,
   `duration_seconds`, `total_tool_calls`, and `completed_at` are
   ever fetched (see `lib/research/og-fetch.ts`). NO `s3_key`
   (path to the full PDF), NO `clerk_id`, NO error messages, NO
   token counts, NO ECS internals, NO `legacy_response`.

2. **Strict delivery gate.** The row must satisfy
   `status = 'completed'` AND one of:

   * **Primary path** — `dr_sms_notifications.sent_at IS NOT NULL`
     for the same `session_id`. Proven delivery of the DR
     completion SMS to a user-visible channel (bubble 1 in
     `/api/sms/research-complete`, stamped just before bubble 2's
     URL fires — see that route's block comment for the exact
     ordering).
   * **Grace path** — `completed_at` within the last **2 hours**.
     Covers the tiny happy-path race between `status = 'completed'`
     being written by the DR backend and our completion route
     stamping `sent_at`. In practice sub-second, but the 2-hour
     bound matches radar-OG for consistency (see `lib/radar/
     og-fetch.ts` — the `OG_DELIVERY_GATE_GRACE_MS` constant is
     re-exported from that module so both surfaces stay in
     lockstep).

   **Why session\_ids need this gate.** Session IDs in the current
   corpus (19,145 completed rows, Sept 2026) are dominated by
   8-character lowercase hex (\~17k+ rows) — a 32-bit space.
   Enumeration at 1000 req/s would find one real hit every
   \~4 min against the current row count. That's why we require
   delivery proof or a recent completion; a bare "completed
   status" gate would give an enumeration attacker persistent
   card-render access to every historical DR — with all the
   sensitive question text those cards carry.

3. **Shape validation.** `session_id` must match
   `/^[A-Za-z0-9_-]{1,64}$/` before touching the DB. Same shape
   used by `SESSION_ID_FORMAT` in `app/api/sms/research-complete/
   route.ts` (line 146) and `app/api/email/scenario-complete/
   route.ts` (line 81). Rejects path traversal, HTML injection,
   SQL-ish payloads, and DoS-via-long-string.

4. **Sanitizer share-through.** The `question` is run through
   `sanitizeRadarSummaryForEmail` before rendering into the image
   or the OG tag, so the same class of LLM-adjacent leaks the
   sanitizer catches on radar summaries (markdown, code fences,
   leaked XML tool-invocation blocks) is caught here too.

5. **Aggressive truncation.** Title cap at 80 chars, description
   cap at 200 chars. For a long-form DR question with
   multi-paragraph planning inputs (verified against a prod row
   containing SSN dollar figures + DOBs), the truncation strips
   the sensitive tail — the title only ever carries the first
   79 chars of the question, which is typically the high-level
   framing ("Give me a road map on how to…") rather than the
   PII-bearing input parameters.

6. **Fail-closed redirects.** Every failure branch returns a
   same-origin 302 redirect to `/opengraph-image` with
   `Cache-Control: no-store`, so a transient DB blip cannot poison
   the CDN with a bad response for a legitimate DR. The `no-store`
   on the redirect means the CDN forwards each request through to
   the origin (which will resolve as soon as the underlying issue
   clears) and never pins a fallback-for-a-live-DR.

## What DOES NOT get an OG card (deliberate)

* **Webapp-triggered DRs** (no SMS handoff row) that completed
  more than 2 hours ago. If a user pastes the URL into Slack /
  email later, they'll see the generic Silvia card. Rationale:
  no delivery proof → the card would be enumeration bait for
  arbitrary historical DRs. If real user demand appears for
  webapp-share cards, we ship a v2 with a different gate (e.g.
  the DR owner's own opt-in click).
* **Running / failed / cancelled DRs.** A preview card advertising
  a DR that then 404s (or 500s) on tap-through would be worse UX
  than the generic card.

## Cache

Happy-path responses carry:

```
Cache-Control: public, max-age=300, s-maxage=600
```

* **`s-maxage=600`** — CDN caches the image for 10 minutes.
* **`max-age=300`** — browsers / third-party unfurl caches keep it
  for 5 min.
* **No `stale-while-revalidate`** — a deleted DR's card is purged
  from the CDN within 10 min, and revalidation on the 11th minute
  hits the origin's 302-redirect branch (which itself carries
  `Cache-Control: no-store`, so the deleted-DR-specific image
  cannot be re-pinned; the unfurl bot follows the redirect to
  `/opengraph-image` — the site-wide Silvia hero — instead).

Same policy as the radar OG so both surfaces share the same
revocation contract. Matches the same "day-long caching would let
a deleted asset's public card linger" security concern the radar
OG addressed in Sept 2026.

## Testing

Unit + route tests total 78+ across three new files (grows as bot
findings land regression guards):

* `lib/research/__tests__/og-fetch.test.ts` — 35 tests (validator +
  fetch + delivery gate + grace window + payload shape)
* `app/(app)/research/__tests__/generate-research-metadata.test.ts`
  — 29 tests (builder + duration/tool-call formatting + PII-safe
  truncation)
* `app/api/og/research/[sessionId]/__tests__/route.test.ts` —
  14 tests (route + 302 fallback + image mint + card content)

Coverage locks in every fail-closed branch, the sanitizer
share-through, Unicode-safe truncation, the strict SMS-proof
delivery gate, the 2-hour grace window, the raw-Postgres timestamp
parsing shared with the radar OG, and the pill / branding
invariants that make the card feel on-brand.

## Follow-ups (deliberately deferred)

* **Webapp-share card gate.** As above, webapp-triggered DRs (no
  SMS handoff) fall back to the generic card. A v2 could open the
  gate for DRs whose owner has explicitly opted-in to sharing
  (e.g. a "Share this report" button that also flips a
  `share_public` flag on the row). No user demand today; ships
  when needed.
* **Card variants.** Phase 1 is a single card layout. If we ever
  want a data-visualisation variant (small sparkline of duration
  vs. token spend, source-mix pie chart, etc.), that's a follow-up
  Satori composition.
* **Explicit cache invalidation.** If a DR gets edited / retracted
  (rare — the question is one-shot), the CDN may serve the old
  card for up to the `s-maxage` window (10 min) before it
  revalidates against the origin. Acceptable for a preview
  surface; if this becomes a real complaint, add a `?v=<timestamp>`
  query param to the OG URL at build time.
* **Metrics.** OG image requests aren't attributed to a specific
  outbound channel today. If important, add a `?src=sms` UTM-style
  query param at the `openGraph.images[].url` build site (lands in
  Vercel Edge logs, can be aggregated post-hoc).


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