T1 spec · Iris Platform

Personalized Voice Invite Links

Status: Mostly done rollout tail remains Owner: Engineering Created: 2026-07-16 Updated: 2026-07-16
Source: Conversational request during a /pipeline build session, 2026-07-16. Type / size: Engineering feature, implemented same-day. Documented after the fact because the change touches the shared assessment/report pipeline and a related access-model RFC already exists in this repo.

1User Requirements

Stated directly, in the order given:

Two non-functional constraints came up alongside these:

2Analysis — What Needed To Be Done

The repo already runs one working pipeline: lead → call → transcript → assessment → org plan → report, used today from the internal /pipeline ops view (src/pipeline/Pipeline.tsx) for one lead at a time. The work was to expose a slice of that pipeline to an external, unauthenticated visitor through a capability link, without rebuilding it.

ReqWhat already existedGap foundResolution
R1 leads table has name/email; /r/:token already proves the "random-token capability link" pattern works in this app No per-person share mechanism — only a static, company-wide slug list (src/company/companyReports.ts) with no individual identity Added shareToken to leads (+ index); leads.createInvite mints one; a new /pipeline panel creates and copies the link
R2 realtime.ts already builds Iris's personalized instructions server-side from lead.name / company / notes — research content never had to touch the browser First cut of getByShareToken returned the entire lead document to the client (internal status, source, raw notes) Tightened the public query to project only { _id, name }; the researched context stays server-side, read directly by realtime.ts when it mints the call session
R3 useRealtimeCall.end()calls.complete (saves transcript) → pipelineNode.runFromTranscript (assessment → plan → report) already runs after every call The assessment/plan/report steps ran on "whichever assessment is most recent in the whole table" — correct for one operator running one call at a time, but a race condition for concurrent external invitees: two people finishing calls close together could receive each other's report Threaded the specific assessmentId through pipelineNodeplansNode.generateForLatestreportsNode.generate, so each call is scoped to its own assessment. Old "latest" behavior is preserved as the default for the internal ops buttons, so nothing there changed
R4 /r/:token + Report.tsx already exist and are unauthenticated None Reused as-is — reportToken from the pipeline is shown directly on the invite page
R5 reportsNode.deliver (Resend + PDF attach) already existed, built for the internal "email report" button It had no external-facing trigger Added an email field + submit button to the invite page's post-call state, calling the same deliver action
R6 Deployed on Vercel with a SPA rewrite; no robots.txt A client-rendered SPA can't noindex one route via a single static index.html Vercel X-Robots-Tag: noindex, nofollow header scoped to /invite/* (authoritative, HTTP-layer) + a JS-injected <meta name="robots"> tag on the page itself (defense in depth)
R7 No new tables beyond one field; no new external services; every backend primitive reused as-is except the two additive assessmentId params above

Follow-up audit: is R3's data actually per-user and retrievable?

Confirmed, with one gap closed R3 was tightened mid-implementation to be explicit that saved data must be specific to that person and persisted in Convex, not just held in browser state. Auditing the chain confirmed the attribution was already correct — calls.leadId (required), assessments.leadId, useCases.leadId/orgPlans.leadId (via assessment.leadId), and reports.leadId (via plan.assessment.leadId) are all set from the same leadId that started the call. The concurrency fix above is what makes that attribution reliable under concurrent invitees.

The audit did surface one real gap: reports, useCases, and orgPlans had no by_lead index, so even though every row was correctly tagged, there was no efficient way to query "this specific person's report" back out — the only place it existed was the reportToken held transiently in the invite page's React state during that one session. Fixed by adding a by_lead index and reports.getByLead query, and surfacing it on each lead row in /pipeline so staff can retrieve a person's saved report from the database after the fact, not only in the moment the call ends.

A gap this analysis surfaced but did not close

Diverges from the existing access-privacy RFC docs/specs/t1-iris-access-and-privacy-rfc.md — an existing RFC in this repo — defines a stricter model for exactly this problem: outbound link → public data → anonymous call → verified-email-owned private result, with retention/deletion rules and no PII on public surfaces. This build is a lighter MVP that satisfies R1–R7 using unguessable tokens as the capability boundary (same pattern as /r/:token today) rather than verified identity. Concretely, it diverges in ways worth tracking:
  • No email verification — anyone who has the invite link can trigger delivery to any email address they type in, not necessarily their own.
  • reports.getByToken still returns deliveredTo (the delivery email) on the public report page — the RFC explicitly calls this out as something to stop doing on public surfaces. Pre-existing, but this feature adds more delivered reports through it.
  • No retention/deletion window for an invite that's never opened.
  • /pipeline, where invites are created, still has no auth in front of it.
None of these block the stated requirements, but they're the first things to revisit if this moves from "send a few personalized LinkedIn links" to a higher-volume or higher-sensitivity outbound motion.

3Technical Concept

3.1  End-to-end flow

Three phases: creation (operator, top), the live call (middle — the only part that talks to OpenAI), and completion (bottom — the only part that talks to Resend). The prospect's browser never talks to OpenAI's API key or Resend directly; Convex actions mediate both.

sequenceDiagram actor Op as Operator participant Pipe as /pipeline (Convex client) participant DB as Convex: leads actor P as Prospect participant Invite as /invite/:token (Convex client) participant RT as Convex action: realtime.createSession participant OAI as OpenAI Realtime (WebRTC) participant Flow as Convex: pipelineNode / assessments / plans / reports participant Resend Op->>Pipe: name, email?, company?, LinkedIn context Pipe->>DB: leads.createInvite(...) DB-->>Pipe: { leadId, shareToken } Pipe-->>Op: /invite/:shareToken (copy link) Op-->>P: send link via LinkedIn DM P->>Invite: open /invite/:token Invite->>DB: leads.getByShareToken(token) DB-->>Invite: { _id, name } only — no company/notes/email/status P->>Invite: click "Start voice call" Invite->>DB: calls.request(leadId) Invite->>RT: realtime.createSession(leadId) Note over RT,DB: server-side only: reads full lead incl.
company/notes, builds Iris's personalized
instructions, calls OpenAI RT-->>Invite: short-lived client secret Invite->>OAI: WebRTC offer (client secret) OAI-->>Invite: audio + live transcript (data channel) P->>Invite: end call Invite->>DB: calls.complete(callId, transcript) Invite->>Flow: pipelineNode.runFromTranscript(transcript, leadId, callId) Flow->>Flow: assessmentsNode.generate → assessmentId Flow->>Flow: plansNode.generateForLatest(assessmentId) Flow->>Flow: reportsNode.generate(assessmentId) → reportToken Flow-->>Invite: { assessmentId, reportToken } Invite-->>P: show anonymous link /r/:reportToken opt Prospect provides email P->>Invite: enter email, submit Invite->>Flow: reportsNode.deliver(reportToken, email, origin) Flow->>Resend: send email (link + PDF attached) Flow->>DB: reports.setDelivered(token, email) end

3.2  What's public vs. server-only (R2 in concrete terms)

The token is the only thing that crosses the trust boundary from operator to prospect. Everything the operator typed into the invite form (company, LinkedIn context) is dereferenced by leadId inside a Convex action, and the result of that dereference (Iris's personalized system prompt) is consumed by OpenAI server-to-server — it's never serialized back to the browser.

flowchart LR subgraph Public["Public — reaches the prospect's browser"] A["/invite/:token URL
(unguessable, noindex)"] B["getByShareToken result
{ _id, name }"] C["/r/:token report
(assessment + plan snapshot)"] end subgraph Server["Server-only — Convex actions, never sent to browser"] D["Full lead doc
(company, notes/research packet,
email, status, source)"] E["OPENAI_API_KEY"] F["RESEND_API_KEY"] end A --> B D -. "read only inside
realtime.createSession" .-> E B -. "leadId used to invoke,
never the data itself" .-> D E --> C F -. "used only inside
reportsNode.deliver" .-> C

3.3  The concurrency fix (assessmentId threading)

assessmentsNode.generate always created a new, correctly-attributed row — the bug was one level up: plansNode.generateForLatest and reportsNode.generate re-derived "which assessment am I building on" by querying order('desc').first() on the whole table instead of being told. That's invisible with a single operator driving one call at a time (/pipeline), which is why it shipped that way originally, but it becomes live with any concurrent external traffic — exactly what invite links introduce. Both actions now accept an optional assessmentId; omitting it keeps the old "latest" behavior for the internal ops buttons, so this was an additive, non-breaking change.

flowchart TB subgraph Before["Before: implicit ‘latest’"] direction LR A1["Call A ends
assessment A inserted"] --> L1["plansNode/reportsNode
query: most recent assessment"] A2["Call B ends
assessment B inserted"] --> L1 L1 -.->|"race: A could get B's plan,
or vice versa"| R1["report"] end subgraph After["After: explicit assessmentId"] direction LR B1["Call A ends
assessment A"] --> P1["plansNode(assessmentId=A)"] --> RA["report A"] B2["Call B ends
assessment B"] --> P2["plansNode(assessmentId=B)"] --> RB["report B"] end

4Security Architecture

This build deliberately trades the verified-identity model in the access-privacy RFC for unguessable capability tokens (R7: “simple implementation that works”). That is a defensible MVP choice, but it shifts the entire security burden onto two things: keeping the tokens secret, and keeping each unauthenticated endpoint from exposing more than the token-holder is entitled to. A review of the shipped code found the token half is sound and the exposure half is not yet. This section documents the model, the concrete gaps, and the hardening that closes them. Nothing here is applied — it is the design to implement.

4.1  Trust model & the core assumption

One architectural fact governs everything below: every Convex query / mutation / action export is a public API endpoint. The deployment URL ships to every browser, so any function can be invoked directly with arbitrary arguments — not only through the UI that happens to call it. “Only /pipeline calls this” is therefore not an access control. The system has two trust zones, and the invite feature widens the first one:

What the design gets right Tokens are crypto.randomUUID() (122-bit v4) — genuinely unguessable. The report lives at /r/:reportToken under a separate token from the invite’s shareToken, and that token is only returned to the browser once a call completes — so knowing the invite link does not, by itself, reveal the report link. noindex is enforced at the authoritative HTTP layer (Vercel X-Robots-Tag) with the client meta tag as defence in depth. Secrets (OPENAI_API_KEY, RESEND_API_KEY) never reach the browser.

4.2  Findings from the code review

The token-secrecy model is undermined by sibling endpoints that dereference an identifier or token into more data — or more action — than the token-holder should get. Severity assumes the outbound motion the feature is built for (links sent to real prospects).

IDSeverityFindingWhy it matters
V1 High R2 is not actually enforced. getByShareToken carefully returns only { _id, name }, but leads.get is a public query that returns the entire lead doc (company, the research packet in notes, email, status). The invite page already holds the leadId. Anyone with an invite link calls leads.get({ id }) directly and reads the private research R2 says must stay server-side. leads.list (also public) dumps every lead in the table — a full prospect-database read.
V2 High reportsNode.deliver is an open email relay. Public, unauthenticated, taking attacker-controlled email and origin. Any report-token holder can send to any address, with the link built from the caller-supplied origin. Spam/relay abuse of the Beam sending domain, plus phishing: a Beam-branded email whose “view report” link points at evil.com/r/…. No rate limit.
V3 Med-High Report token derivable from the invite link. reports.getByLead is a public query keyed by leadId, and leadId is handed to the invite page by getByShareToken. Defeats the “only the person who completed the flow has the report link” property: a link-holder can fetch the report token without ever taking the call. With other leadIds (V1), it reaches other people’s reports.
V4 Med calls.complete accepts any transcript for any call. Public, no ownership or state check. A finished call’s transcript can be overwritten, and a fully attacker-controlled transcript can be pushed into the assessment LLM pipeline (content/prompt-injection into generated reports).
V5 Med Destructive operator mutations are public. reports.clearLatest, leads.remove, leads.setStatus, leads.add / capture are all callable without auth. clearLatest needs no arguments — an attacker can delete reports as they are generated. General data tampering and spam-lead creation. Same root cause as the un-authed /pipeline.
V6 Low Delivery email leaked on the public report. reports.getByToken returns deliveredTo on the public /r/:token surface. Capability URLs also leak via Referer, history, and link-preview crawlers. PII (the recipient email) exposed to anyone the forwarded report link reaches. Already flagged in the RFC; this feature adds more delivered reports through it.

4.3  Recommended hardening

The fixes fall into two buckets: shrink each unauthenticated endpoint’s exposure (self-contained, no new infrastructure), and put the operator zone behind auth (the one item that needs a dependency the repo has not wired yet).

FixClosesChange
H1 V1 Make leads.get an internalQuery. Only realtime.createSession reads the full lead, and it runs server-side — so it can call internal.leads.get while the function disappears from the public API. No frontend uses api.leads.get, so this is non-breaking. The research packet can no longer be dereferenced from a leaked leadId.
H2 V1, V3, V5 Put the operator zone behind authentication. Introduce a single requireOperator(ctx) gate (ctx.auth.getUserIdentity(), per CLAUDE.md) and apply it to leads.list, getByLead, createInvite, setStatus, remove, reports.latest / clearLatest, and the seed/remove helpers. This is the highest-leverage fix and the one external dependency (Clerk is referenced in the Beam stack but not yet wired here).
H3 V2 Harden deliver: derive the link origin from a server env var (APP_ORIGIN) instead of a client parameter; validate the recipient as a single address; and allow exactly one delivery per report token, claimed transactionally before the send. This removes the phishing vector and caps the relay to one email per unguessable token.
H4 V6 Drop deliveredTo / deliveredAt from the public reports.getByToken projection. Operator surfaces read those fields through internal, auth-gated queries instead.
H5 V4 Guard calls.complete so it only transitions a call that is still in_progress; re-completion becomes a no-op. Prevents overwriting stored transcripts and re-triggering the pipeline on a finished call.
H6 hardening + reliability Move email delivery to an async, server-scheduled job: capture the optional email before hang-up, and once the report is generated, schedule delivery to that captured address via ctx.scheduler. Delivery then survives the visitor closing the tab, and the browser never supplies the recipient or origin — reinforcing H3.
H7 hygiene Define a retention / expiry policy for invite tokens that are never opened and report tokens whose call never completes, so stale capability URLs do not accumulate indefinitely.
Sequencing note H1, H3, H4, H5 are self-contained and non-breaking — ship them first. H2 is the real architectural boundary and depends on wiring authentication in front of /pipeline; until it lands, the operator-zone findings (V3, V5) remain open by the same root cause the RFC already tracks. H6 and H7 are follow-ups that make the external flow robust and tidy.

4.4  Mapping to requirements

How each stated requirement fares under the current build, and what makes it hold.

ReqSecurity propertyStatus & what closes it
R1 Per-person link is unguessable Holds. 122-bit random shareToken.
R2 URL only reaches that person’s researched data, not the wider set Not yet enforced — the projection in getByShareToken is undone by public leads.get and leads.list. Closed by H1 (+ H2 for the operator dump).
R3 Saved data is per-person and reliably attributed Holds. Attribution via leadId is correct; the concurrency fix (§3.3) keeps it reliable under concurrent invitees.
R4 Anonymous report link, separate from the invite link Holds by construction, but the “only the completer has it” guarantee currently leaks via reports.getByLead. Closed by H2.
R5 Optional email delivery without abuse Works but abusable as an open relay / phishing vector. Closed by H3 and reinforced by H6.
R6 Not indexed by search engines Holds. Authoritative HTTP header + client meta tag. Residual: capability URLs still leak via Referer / previews — acceptable at low volume.

The attack chain the hardening breaks: today a single leaked invite link walks all the way to another person’s research, report token, and delivery email. H1 + H2 + H4 sever each hop.

flowchart TB subgraph Now["Today — one invite link, full walk"] direction TB I1["invite link
/invite/:token"] --> L1["getByShareToken
→ leadId"] L1 --> G1["leads.get(leadId)
→ full research + email"] L1 --> GL1["reports.getByLead(leadId)
→ report token"] GL1 --> RT1["reports.getByToken
→ report + deliveredTo (PII)"] L1 --> LL1["leads.list()
→ every other lead"] end subgraph Hardened["Hardened — each hop severed"] direction TB I2["invite link
/invite/:token"] --> L2["getByShareToken
→ leadId + name only"] L2 -.->|"H1: internalQuery"| X1["leads.get
server-only ✗"] L2 -.->|"H2: requireOperator"| X2["getByLead / list
auth-gated ✗"] L2 --> OK["can start the call —
nothing else"] end

Follow-ups Not Done Here