# Public Artifact Views — Design

**Date**: 2026-04-19 · **Status**: Proposal · **Follow-up to**: `CLAIM_AUDIT_2026-04-19.md`

## The distinction we were missing

Public URLs in NodeBench come in **two fundamentally different shapes**. Confusing them produced the audit gaps §5b (founder profile) and §5c (market maps).

| Shape | Keyed by | Durability | Example | Purpose |
|---|---|---|---|---|
| **Entity-first** | `slug` | Living — reflects current state | `/company/softbank` | "What's the state of this thing today?" |
| **Artifact-first** | `id` | Frozen — snapshot of one pipeline run | `/memo/abc123` | "What did NodeBench produce on 2026-04-19?" |

Both are valid and both should exist. They answer different questions and link differently.

## Current state (2026-04-19)

| Route | View | Shape | Status |
|---|---|---|---|
| `/company/:slug` | `PublicCompanyProfileView` | Entity-first, `entityType === "company"` | ✅ ships |
| `/memo/:id` | `ShareableMemoView` | Artifact-first, decision memo | ✅ ships |
| — | — | — | Everything below is missing |

## Target state

### Entity-first (`/:type/:slug`, one per entity type)

```
/company/:slug    →  PublicCompanyProfileView       (exists)
/founder/:slug    →  PublicFounderProfileView       (new — this doc)
/market/:slug     →  PublicMarketMapView            (new — this doc)
```

Each is the "living" artifact for that entity type. URL is stable across revisions. Content reflects whatever the entity holds RIGHT NOW.

### Artifact-first (`/:artifact/:id`, one per output type)

```
/memo/:id         →  ShareableMemoView              (exists)
/product/:id      →  PublicProductProfileView       (new — this doc)
```

`PublicProductProfileView` is the **snapshot** view — "NodeBench ran its pipeline on softbank on 2026-04-19 and produced this analysis." Captures:
- Entity slug + name at the time of the run
- Full tool trace (which tools called, in which order, with what budget)
- Exact sources cited (URLs + excerpts, frozen)
- All output sections as they were rendered
- Timestamp + optional author

Why this matters: the living `/company/softbank` page keeps changing. A VP you send that link to today sees one thing; in a week they see something else. A pipeline snapshot `/product/xyz789` is immutable and always shows what was produced in that run — the right link to cite in a report.

## Two views are not enough — why three entity-first views

| Entity type | Data shape | Primary lens |
|---|---|---|
| **company** | Revenue, traction, funding history, team, competitors | "Is this company a good investment / partnership / threat?" |
| **person** (`founder`) | Experience, affiliations, network, compensation, contact intent | "Is this person someone I should meet / hire / back?" |
| **market** | Axes of positioning, players as nodes, clustering, gaps | "What does this market look like and where's the whitespace?" |

Rendering a person on the "company profile" template produces awkward output (a 2D scatter is meaningless; funding history is the wrong question). Rendering a market the same way is worse — it flattens a landscape into one node.

## What each new view renders

### `PublicFounderProfileView` at `/founder/:slug`

Hero: face (optional) + name + current affiliation + primary role.
Sections:
1. **Background timeline** — roles ordered by date, with each role a dot on a vertical line
2. **Affiliations** — companies / funds / schools with linked entity pages
3. **Network** — people mentioned alongside this founder in pipeline runs, ranked
4. **Contact intent** — "Why would I reach out?" summary (warm intro path if known)
5. **Evidence** — sources that informed every claim, same pattern as memo

Route handler: add `isFounderRoute = location.pathname.startsWith("/founder/")` branch in `src/App.tsx` alongside the existing `isCompanyRoute`.

### `PublicMarketMapView` at `/market/:slug`

Hero: market name + one-line positioning statement.
Sections:
1. **The map** — 2D SVG scatter. X-axis and Y-axis chosen per-market (funding stage × traction, commercial maturity × technical moat, etc.). Each company node rendered with logo + clickable → `/company/<slug>`
2. **Clusters** — named groups (e.g. "Foundation models", "Fine-tuning tools", "Retrieval")
3. **Whitespace** — explicit "areas with no player" call-outs
4. **Players list** — fallback text table for screen readers + print

Route handler: `isMarketRoute = location.pathname.startsWith("/market/")` branch.

Backend: new `productMarketMaps` table storing (axes, nodeLayouts, clusters, whitespace, sourceRefs) — this is the missing piece from audit §5c.

### `PublicProductProfileView` at `/product/:id`

Hero: entity name (as of the run) + "Generated by NodeBench on <date>" stamp.
Sections:
1. **The output** — whatever the pipeline produced, rendered verbatim (no "current state" slot)
2. **Tool trace** — compact timeline of which tools ran, what they returned, in what order. Exact bytes from the run, not re-fetched.
3. **Sources cited** — frozen list; clicking a source shows the excerpt as it was when cited
4. **What would be different now?** — optional diff against the live entity page if still exists; link labeled "see current state of <entity>"

Route handler: `isProductRoute = location.pathname.startsWith("/product/")` branch.

Backend: new `productPipelineSnapshots` table with (entityId OR entitySlug frozen-string, runId, producedAt, toolTrace, sources, outputSections). Immutable once written.

## On-paste hydration — the composer's side of this story

Every artifact-first snapshot has to START somewhere. On-paste hydration makes that start cheaper:

| Paste input | Detection heuristic | Pre-fill |
|---|---|---|
| `https://linkedin.com/in/*` | URL regex | `entityTypeHint = "person"`; call `extractLinkedInPublicIdentifier` (exists); render a "Founder · <handle>" chip |
| `https://linkedin.com/company/*` | URL regex | `entityTypeHint = "company"`; render a "Company · <handle>" chip |
| `application/pdf` with ≥ 5 pages and > 50% of pages image-dominant | MIME + page-count + image-ratio after client-side PDF.js parse | `intentHint = "pitch_deck"`; render a "Pitch deck · N slides" chip |
| Text starts with recruiter-prefix patterns | simple prefix regex | `intentHint = "recruiter_note"`; render a "Recruiter note" chip |
| Text has "Previously:" / "Role:" / year-ranges | prefix regex | `intentHint = "bio"`; render a "Bio" chip |

Implementation: `src/features/product/lib/onPasteHydration.ts` — one file, one function per detector, zero dependencies beyond what already ships. Wired into `ProductIntakeComposer.onPaste` and `onDrop`.

Closes audit §4a-§4d at the "label the intake correctly" tier. Fuller ingestion (actually reading the pitch deck's slides, parsing the bio into structured fields) is its own later work.

## Implementation staging

Ordered by cheapest-first. Every step is reversible.

**PR-1 (~2 hrs)**: `onPasteHydration.ts` + chips in the composer. Pre-routes `entityTypeHint` and `intentHint` into the session before the pipeline runs.

**PR-2 (~3 hrs)**: `PublicFounderProfileView` at `/founder/:slug`. Route branch in `App.tsx` mirrors `PublicCompanyProfileView`. Render from `entityType === "person"` data.

**PR-3 (~1 day)**: `productPipelineSnapshots` table + freeze-on-complete hook + `PublicProductProfileView` at `/product/:id`. The artifact-first snapshot story.

**PR-4 (~1-2 weeks)**: `productMarketMaps` table + agent flow that positions ≥ 5 companies on chosen axes + `PublicMarketMapView` at `/market/:slug`. Closes audit §5c (market maps) as a real deliverable.

## Naming contract (never rename, always add)

Once a `Public*View` ships to prod and real URLs exist in the wild, **never rename it**. Add a new view instead. Old URLs must keep working.

So:
- `PublicCompanyProfileView` stays `PublicCompanyProfileView` forever
- `PublicFounderProfileView` is a sibling, not a replacement
- `PublicProductProfileView` is a sibling, not a replacement
- `ShareableMemoView` stays `ShareableMemoView`

If the design later wants "unified" rendering, do it at the dispatcher level (`App.tsx` route branch picks the right view by entity type / artifact type), not by repurposing a name.

## What this closes in the marketing claim

| Claim phrase | After PR-1 | After PR-4 |
|---|---|---|
| "ingests recruiter notes, LinkedIn URLs, pitch decks, and bios from a single input" | ✅ (paste hydration labels all four) | ✅ |
| "outputs decision memos" | ✅ (unchanged) | ✅ |
| "outputs founder profiles" | partial (`/founder/:slug` route but flow still routes persons to `PublicCompanyProfileView`) | ✅ if PR-2 ships |
| "outputs market maps" | ⬜ still missing | ✅ |
| "as shareable public URLs" | ✅ (unchanged) | ✅ |
