diff --git a/.context/app/planning/development-plan.md b/.context/app/planning/development-plan.md
index 51f1c6cf..dab2980f 100644
--- a/.context/app/planning/development-plan.md
+++ b/.context/app/planning/development-plan.md
@@ -667,13 +667,13 @@ _Indicative tasks:_
### F8.3 — Anonymous-mode hardening
-_Status:_ not started · _Size:_ ~1–2 PRs · _Owner:_ TBD · _Deps:_ F8.1, F8.2 + any surface that touches session data
+_Status:_ in flight ([tracker](./features/f8.3.md)) · _Size:_ ~1–2 PRs · _Owner:_ TBD · _Deps:_ F8.1, F8.2 + any surface that touches session data
Verification pass across every surface that touches session data, ensuring no PII leak when `anonymousMode = true`. Flag-gating tightened where needed.
_Indicative tasks:_
-- Audit every read path that touches `AppQuestionnaireUserProfile` for anonymous-mode gating.
+- Audit every read path that touches `AppRespondentProfileSnapshot` for anonymous-mode gating.
- Audit exports + analytics + admin UI.
- Integration tests that flip the flag and assert PII absence on every surface.
- Documentation of the anonymous-mode contract.
diff --git a/.context/app/planning/features/f8.3.md b/.context/app/planning/features/f8.3.md
new file mode 100644
index 00000000..d97209f7
--- /dev/null
+++ b/.context/app/planning/features/f8.3.md
@@ -0,0 +1,104 @@
+---
+feature: F8.3
+title: Anonymous-mode hardening (+ respondent profile collection)
+phase: P8 — Admin analytics, exports, anonymous mode
+status: in flight
+owner: TBD
+deps: F8.1 (analytics surfaces), F8.2 (result exports), F7.4 (PDF export), F6.1 (sessions)
+opened: 2026-06-09
+plan: .context/app/planning/development-plan.md#f83--anonymous-mode-hardening
+docs: .context/app/questionnaire/anonymous-mode.md
+---
+
+# F8.3 — Anonymous-mode hardening (+ respondent profile collection)
+
+> Committable tracker for **F8.3**. The cross-surface PII pass: guarantee no respondent
+> identity leaks when a version's `anonymousMode = true`, across exports, analytics, and
+> the admin UI. Pulls in the previously-unbuilt **respondent profile collection** (model +
+> form + capture) so the snapshot is the thing anonymous mode must gate. Gated by
+> `APP_QUESTIONNAIRES_ENABLED`.
+
+## Intent
+
+`anonymousMode` had, until now, meant "open / no-invitation" — and the authenticated
+anonymous-direct path still bound `respondentUserId` ("the admin-side identity redaction is
+a later phase", per the F6.1 seam comment). F8.3 is that phase. It makes the flag a real
+PII contract honoured at every data boundary, and adds the profile-collection capability
+(net-new — no form, capture, or storage existed) so there's a respondent profile for the
+contract to gate.
+
+## Decisions (confirmed with the user)
+
+- **Full scope, including the form.** Build the `AppRespondentProfileSnapshot` model, the
+ capture seam, the read-path redaction, AND the respondent-facing profile form — not just
+ the server contract.
+- **D1 — no row, not an empty row.** An anonymous session writes **no** snapshot at all.
+ Absence is the strongest, most testable invariant (a test asserts `create` was never
+ called). Read paths additionally null the profile when anonymous (defence in depth).
+- **Form only on the non-anonymous surface.** The profile form renders only for a
+ non-anonymous version with profile fields (`loadStartContext` gate); the anonymous /
+ no-login flow never shows it. So `anonymousMode = true` ⇒ no PII collected at all.
+- **Analytics: guard + k-anonymity.** `K_ANONYMITY_THRESHOLD = 5`. Below that many
+ non-preview sessions, granular detail is withheld (distributions detail, funnel counts,
+ cost top-sessions). Aggregate spend is always returned (no identity). Cost additionally
+ drops the per-session table whenever anonymous (session ids re-identify).
+- **Modelled `User` FK with `onDelete: Cascade`.** The snapshot is PII, so it breaks the
+ deferred-UG-1 plain-scalar posture and cascades natively — `eraseUser()` needs no hook.
+
+## Build shape (branch `feat/F8.3-anonymous-mode-hardening`)
+
+- **Privacy constants** — `lib/app/questionnaire/analytics/privacy.ts`:
+ `K_ANONYMITY_THRESHOLD = 5`, `isCohortSuppressed(n)`. Pure / client-safe so admin panels
+ import the threshold for labels. Re-exported from `analytics/index.ts`.
+- **Model + migration** — `AppRespondentProfileSnapshot` in `app-questionnaire.prisma`
+ (migration `20260609062611_app_respondent_profile_snapshot`, `--create-only` + phantom
+ pgvector DROPs stripped). Reverse relation on `AppQuestionnaireSession` and on `User`.
+- **Profile validation** — `lib/app/questionnaire/profile/profile-values.ts`:
+ `validateProfileValues` (strict — rejects unknown keys, enforces required, coerces
+ types), `parseProfileFields`, `asProfileValues`. Reused by the form and the seam.
+- **Capture seam** — `questionnaire-sessions/_lib/create.ts`: `createSessionFromInvitation`
+ threads `profileValues`, validates, and writes the snapshot inside the create transaction
+ — only when non-anonymous and values present. The version-direct and no-login paths never
+ capture. Route (`questionnaire-sessions/route.ts`) accepts `profileValues` on the
+ invitation body.
+- **Respondent form** — `components/app/questionnaire/profile/profile-start-form.tsx`
+ (react-hook-form + Zod + `FieldHelp`), gated onto the start page by
+ `lib/app/questionnaire/chat/start-context.ts` (`loadStartContext`).
+- **Read-path redaction** — `profile` carried + gated in `export/results-loader.ts` (+
+ `results-types.ts`, `results-serialize.ts` `respondent_profile` column),
+ `questionnaire-sessions/_lib/session-export.ts`, `export/build-session-export-model.ts` (+
+ `types.ts`), rendered in `components/app/questionnaire/export/session-pdf-document.tsx`.
+- **Analytics hardening** — `analytics/{cost,funnel,distributions}.ts` gain cohort
+ suppression + (cost) the anonymous guard; new `suppressed` / `topSessionsSuppressed`
+ result fields + `{ kind: 'suppressed' }` distribution variant in `views.ts`; admin panels
+ render the suppressed states.
+
+## Anonymous-mode contract
+
+The canonical statement of the invariant, the per-surface gate table, the snapshot rule,
+k-anonymity, and the erasure cascade lives in
+[`../../app/questionnaire/anonymous-mode.md`](../../app/questionnaire/anonymous-mode.md).
+
+## Tests
+
+- `tests/integration/.../questionnaire-sessions/profile-snapshot.test.ts` — capture
+ invariants (the core: anonymous never writes a snapshot; invalid/empty rejected/skipped;
+ no capture on resume).
+- `tests/unit/lib/app/questionnaire/profile/profile-values.test.ts` — the validator.
+- `tests/unit/lib/app/questionnaire/analytics/{cost,funnel,distributions}.test.ts` —
+ suppression + anonymous guard (existing cohorts bumped to ≥5; suppression tests added).
+- `tests/unit/lib/app/questionnaire/export/*` + `session-export.test.ts` — profile
+ surfaced when not anonymous, dropped when anonymous.
+- `tests/unit/prisma/app-questionnaire-schema.test.ts` — model shape + **both FKs
+ `ON DELETE CASCADE`** (the GDPR erasure contract).
+
+## Erasure
+
+`AppRespondentProfileSnapshot.user` is `onDelete: Cascade`, so `eraseUser()` removes the
+snapshot via the native cascade — no cleanup hook. Noted in
+[`../../privacy/data-erasure.md`](../../privacy/data-erasure.md).
+
+## No CHANGELOG entry
+
+App-owned models/routes are not part of the Sunrise platform surface — per the repo's
+platform-scoped CHANGELOG policy, F8.3 adds no `CHANGELOG.md` bullet.
diff --git a/.context/app/questionnaire/README.md b/.context/app/questionnaire/README.md
index 3385f656..7cfaa210 100644
--- a/.context/app/questionnaire/README.md
+++ b/.context/app/questionnaire/README.md
@@ -27,6 +27,7 @@ plan and feature trackers, see [`../planning/`](../planning/); for the platform
| [`per-turn-orchestrator.md`](./per-turn-orchestrator.md) | The live streaming turn loop — pure orchestrator, SSE route, 3 access scenarios (incl. no-login anonymous), streamed offers (F6.1) |
| [`cost-cap-enforcement.md`](./cost-cap-enforcement.md) | Per-session USD budget at the turn boundary — soft wrap-up nudge at 90%, hard 402 + auto-pause at 100%, summed turn cost, dark-launch flag (F6.3) |
| [`answer-slot-panel.md`](./answer-slot-panel.md) | The live respondent answer panel beside the chat — `GET …/answers` read endpoint, scope config, confidence language, Revisit wiring (F7.2) |
+| [`anonymous-mode.md`](./anonymous-mode.md) | The cross-surface PII contract — per-surface gates, the profile snapshot rule, k-anonymity suppression, erasure cascade (F8.3) |
## Where the code lives
diff --git a/.context/app/questionnaire/anonymous-mode.md b/.context/app/questionnaire/anonymous-mode.md
new file mode 100644
index 00000000..9c09ce1a
--- /dev/null
+++ b/.context/app/questionnaire/anonymous-mode.md
@@ -0,0 +1,73 @@
+# Anonymous mode — the PII contract (F8.3)
+
+A version-level boolean, `AppQuestionnaireConfig.anonymousMode` (default `false`), governs
+whether respondent identity may be **collected, persisted, or surfaced** for that version.
+F8.3 hardens the guarantee across every surface that touches session data and adds the
+respondent **profile snapshot** (collected only on the non-anonymous surface).
+
+## The invariant
+
+When `anonymousMode = true`, for that version:
+
+- **No identity is persisted that links a session to a person.** Authenticated
+ anonymous-direct and no-login sessions bind `respondentUserId = null` / mint a signed
+ token; profile fields are never collected.
+- **No identity reaches any admin read surface.** Respondent name, the profile snapshot,
+ and raw conversational turns are all dropped at the **data boundary** (the loader /
+ aggregator), not merely hidden in the UI.
+- **Granular analytics that could re-identify a small cohort are withheld** (k-anonymity).
+
+Anonymity is about not linking data to a person — it is **not** about redacting the survey
+data itself. Structured answer _values_ are always exported (they're the point of the
+export); what's withheld is identity, free-text prose, and small-cohort detail.
+
+## Per-surface gates
+
+| Surface | File | Behaviour when `anonymousMode = true` |
+| ---------------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
+| Authed-direct session create | `questionnaire-sessions/_lib/create.ts` | `respondentUserId` bound, but **no profile snapshot** ever written |
+| No-login session create | `questionnaire-sessions/_lib/create.ts` (`createAnonymousSession`) | `respondentUserId = null`; no profile |
+| Profile capture | `create.ts` (`resolveProfileCapture`) | Skipped entirely — short-circuits on the anonymous flag |
+| Single-session PDF | `questionnaire-sessions/_lib/session-export.ts` + `export/build-session-export-model.ts` | Identity query skipped; `respondent` and `profile` null |
+| Bulk CSV/JSON export | `export/results-loader.ts` | Names skipped; `turns = []`; `profile = null` per session |
+| Distributions analytics | `analytics/distributions.ts` | Identity-free by construction; small-cohort detail suppressed (below) |
+| Funnel analytics | `analytics/funnel.ts` | Counts-only; small-cohort counts suppressed |
+| Cost analytics | `analytics/cost.ts` | Per-session spend table dropped (session ids are a re-identification handle) |
+| Invitations | `questionnaires/[id]/invitations/_lib/read.ts` | Orthogonal — invitations are the _invited_ (non-anonymous) surface; an anonymous version has none |
+
+## The profile snapshot rule
+
+`AppRespondentProfileSnapshot` (1:1 with a session) holds the `profileFields` values a
+respondent supplied at session start. **Decision D1 — no row, not an empty row:** an
+anonymous session writes **no** snapshot at all. Absence is the strongest, most testable
+invariant — a test asserts `appRespondentProfileSnapshot.create` was never called, and
+there is structurally no PII at rest. Read paths additionally null the profile when
+anonymous, as defence in depth.
+
+Capture happens only on the **invitation** surface (always non-anonymous), at session
+create, inside the same transaction. The respondent form lives at
+`components/app/questionnaire/profile/profile-start-form.tsx`, gated by
+`loadStartContext` so it renders only for a non-anonymous version with profile fields.
+
+## k-anonymity suppression
+
+`K_ANONYMITY_THRESHOLD = 5` (`analytics/privacy.ts`, client-safe so admin panels label it).
+Below this many non-preview sessions, granular analytics detail is withheld — a tiny
+sample can re-identify an individual answer. Applied at the aggregator:
+
+- **distributions** — per-question `detail` becomes `{ kind: 'suppressed' }`, counts zeroed,
+ result `suppressed: true`.
+- **funnel** — all stage + anonymous counts zeroed, `suppressed: true`.
+- **cost** — the top-spend-session table emptied (`topSessionsSuppressed: true`); aggregate
+ spend (total / by-capability / trend) carries no identity and is always returned.
+
+An empty cohort (`0` sessions) is **not** "suppressed" — it genuinely has no data.
+
+## Erasure
+
+`AppRespondentProfileSnapshot` is the **first** questionnaire model with a modelled `User`
+FK (the deferred-UG-1 "plain String, no relation" posture is deliberately broken because
+this row IS personal data). Both FKs declare `onDelete: Cascade`: the session FK (owned
+data) and the user FK (personal data). The user cascade means `eraseUser()`'s
+`prisma.user.delete()` removes the snapshot natively — **no erasure hook needed**. See
+[`../../privacy/data-erasure.md`](../../privacy/data-erasure.md).
diff --git a/.context/app/questionnaire/configuration.md b/.context/app/questionnaire/configuration.md
index 4af53477..bb3ea3c7 100644
--- a/.context/app/questionnaire/configuration.md
+++ b/.context/app/questionnaire/configuration.md
@@ -59,6 +59,11 @@ needs no separate model (the same shape precedent as `audience` / `typeConfig`).
`key` is a unique lowercase slug; `options` is required (non-empty, distinct) for
`select` and forbidden for every other type.
+The values a respondent supplies for these fields are collected at session start and
+persisted to `AppRespondentProfileSnapshot` — **only on the non-anonymous surface**. When
+`anonymousMode = true` no profile is collected, stored, or surfaced. See
+[`anonymous-mode.md`](./anonymous-mode.md) for the full PII contract (F8.3).
+
## Lazy materialization
No config row exists until the admin first saves — this keeps the F1.1 ingest path
diff --git a/.context/app/questionnaire/schema.md b/.context/app/questionnaire/schema.md
index 4da989d3..8cba1b76 100644
--- a/.context/app/questionnaire/schema.md
+++ b/.context/app/questionnaire/schema.md
@@ -271,4 +271,18 @@ questionSlotId)` (the upsert unique), with `value Json`, `provenanceLabel`,
Migration hand-stripped of the phantom pgvector DROPs (schema-shape test guards the
strip). See [`per-turn-orchestrator.md`](./per-turn-orchestrator.md).
+### Respondent profile snapshot (F8.3 — P8)
+
+- **`AppRespondentProfileSnapshot`** (F8.3, migration
+ `20260609062611_app_respondent_profile_snapshot`) — the `profileFields` values a
+ respondent supplied at session start, 1:1 with a session (`sessionId @unique`). `values
+Json` (keyed by field `key`), `respondentUserId String?` denormalised from the session.
+ **The first questionnaire model with a modelled `User` FK** — the deferred-UG-1
+ "plain String, no `@relation`" posture is deliberately broken because this row IS personal
+ data and must cascade on erasure. Both FKs `onDelete: Cascade`: the session FK (owned
+ data) and the user FK (so `eraseUser()` removes it natively, no hook). **Never written for
+ an anonymous session** (no row, not an empty row). Migration hand-stripped of the phantom
+ pgvector DROPs (schema-shape test guards the strip + asserts both cascades). See
+ [`anonymous-mode.md`](./anonymous-mode.md).
+
_Later phases extend this file. Each documents its models here as it lands._
diff --git a/.context/privacy/data-erasure.md b/.context/privacy/data-erasure.md
index c7bcdbc1..834c99aa 100644
--- a/.context/privacy/data-erasure.md
+++ b/.context/privacy/data-erasure.md
@@ -137,6 +137,13 @@ Two failure modes if the migration FK is wrong:
So the migration FK with an explicit `ON DELETE` is **mandatory**, not optional.
+**ConQuest exception — a modelled `User` FK.** `AppRespondentProfileSnapshot` (F8.3,
+respondent profile values, PII) deliberately breaks the plain-scalar pattern: it adds a
+real `@relation` with a reverse field on `User`, FK `onDelete: Cascade`. Because it
+cascades natively, `eraseUser()` removes it with no cleanup hook. It is the one app table
+that _is_ caught by the schema-level `@relation onDelete` review. See
+[`../app/questionnaire/anonymous-mode.md`](../app/questionnaire/anonymous-mode.md).
+
### What the FK cascade can't do — register a cleanup hook
A `CASCADE` FK is erased automatically by `prisma.user.delete()`. But, exactly as
diff --git a/app/(protected)/questionnaires/start/page.tsx b/app/(protected)/questionnaires/start/page.tsx
index 71145a78..a5ac540d 100644
--- a/app/(protected)/questionnaires/start/page.tsx
+++ b/app/(protected)/questionnaires/start/page.tsx
@@ -10,6 +10,8 @@ import {
createOrResumeAuthedSession,
type AuthedSessionRequest,
} from '@/lib/app/questionnaire/chat/session-bootstrap';
+import { loadStartContext } from '@/lib/app/questionnaire/chat/start-context';
+import { ProfileStartForm } from '@/components/app/questionnaire/profile/profile-start-form';
export const metadata: Metadata = {
title: 'Start questionnaire',
@@ -53,6 +55,20 @@ export default async function StartQuestionnairePage({
? `?invitationToken=${encodeURIComponent(sp.invitationToken)}`
: `?versionId=${encodeURIComponent(sp.versionId ?? '')}`;
clearInvalidSession(`/questionnaires/start${query}`);
+ return null; // unreachable — clearInvalidSession redirects
+ }
+
+ // F8.3: a non-anonymous questionnaire with profile fields collects them BEFORE the
+ // session is created. The form posts the values back into the create route (which
+ // writes the snapshot atomically); a resumable session skips straight to the chat.
+ const context = await loadStartContext(request, session.user.id);
+ if (context.kind === 'resume') {
+ redirect(`/questionnaires/${context.sessionId}`);
+ }
+ if (context.kind === 'needs-profile' && 'invitationToken' in request) {
+ return (
+
+ );
}
const result = await createOrResumeAuthedSession(request);
diff --git a/app/api/v1/app/questionnaire-sessions/_lib/create.ts b/app/api/v1/app/questionnaire-sessions/_lib/create.ts
index 7ab2bf9f..a5a4d0f2 100644
--- a/app/api/v1/app/questionnaire-sessions/_lib/create.ts
+++ b/app/api/v1/app/questionnaire-sessions/_lib/create.ts
@@ -19,8 +19,15 @@
*/
import { prisma } from '@/lib/db/client';
+import type { Prisma } from '@prisma/client';
import { hashInvitationToken } from '@/lib/app/questionnaire/invitations';
+import { findResumableSession } from '@/lib/app/questionnaire/chat/resumable-session';
import { recordSessionCreated } from '@/app/api/v1/app/questionnaires/_lib/sessions';
+import {
+ parseProfileFields,
+ validateProfileValues,
+ type ProfileValues,
+} from '@/lib/app/questionnaire/profile/profile-values';
/** A created-or-resumed session, or a typed failure the route maps to an HTTP status. */
export type CreateSessionResult =
@@ -32,22 +39,44 @@ export type CreateSessionResult =
}
| { ok: false; status: number; code: string; message: string };
-/** Find a respondent's existing non-terminal real session for a version (resume target). */
-async function findResumableSession(
- versionId: string,
- respondentUserId: string
-): Promise<{ id: string; status: string; versionId: string } | null> {
- const row = await prisma.appQuestionnaireSession.findFirst({
- where: {
- versionId,
- respondentUserId,
- isPreview: false,
- status: { in: ['active', 'paused'] },
- },
- orderBy: { createdAt: 'desc' },
- select: { id: true, status: true, versionId: true },
+/**
+ * Validate a respondent's profile submission against a version's configured fields,
+ * for the NON-anonymous capture seam. Anonymous mode short-circuits to "nothing to
+ * capture" — anonymous sessions never collect profile data (the F8.3 invariant).
+ *
+ * The server is the enforcing boundary, not the form: an OMITTED `profileValues` is
+ * validated as an empty submission, so a version with a required field rejects with a
+ * 400 even when a direct API caller sends no values (the form always submits them).
+ * Versions with no fields, or only optional fields the caller omitted, capture nothing.
+ * Returns the values to persist (or null), or a typed 400 to reject.
+ */
+function resolveProfileCapture(
+ anonymous: boolean,
+ profileFieldsJson: unknown,
+ rawValues: Record | undefined
+):
+ | { ok: true; values: ProfileValues | null }
+ | { ok: false; status: number; code: string; message: string } {
+ if (anonymous) return { ok: true, values: null };
+ const fields = parseProfileFields(profileFieldsJson);
+ if (fields.length === 0) return { ok: true, values: null };
+ const result = validateProfileValues(fields, rawValues ?? {});
+ if (!result.ok) {
+ return { ok: false, status: 400, code: 'INVALID_PROFILE', message: result.message };
+ }
+ return { ok: true, values: Object.keys(result.values).length > 0 ? result.values : null };
+}
+
+/** Persist a profile snapshot inside the session-create transaction (non-anonymous only). */
+async function writeProfileSnapshot(
+ tx: Prisma.TransactionClient,
+ sessionId: string,
+ respondentUserId: string | null,
+ values: ProfileValues
+): Promise {
+ await tx.appRespondentProfileSnapshot.create({
+ data: { sessionId, respondentUserId, values },
});
- return row;
}
/**
@@ -57,7 +86,8 @@ async function findResumableSession(
*/
export async function createSessionFromInvitation(
token: string,
- respondentUserId: string
+ respondentUserId: string,
+ profileValues?: Record
): Promise {
const invitation = await prisma.appQuestionnaireInvitation.findUnique({
where: { tokenHash: hashInvitationToken(token) },
@@ -66,7 +96,12 @@ export async function createSessionFromInvitation(
userId: true,
status: true,
versionId: true,
- version: { select: { status: true } },
+ version: {
+ select: {
+ status: true,
+ config: { select: { anonymousMode: true, profileFields: true } },
+ },
+ },
},
});
@@ -100,6 +135,16 @@ export async function createSessionFromInvitation(
};
}
+ // Validate any profile submission against the version's fields before any write — an
+ // invitation surface is never anonymous, so its profile fields (if any) are collected.
+ const anonymous = invitation.version.config?.anonymousMode ?? false;
+ const capture = resolveProfileCapture(
+ anonymous,
+ invitation.version.config?.profileFields,
+ profileValues
+ );
+ if (!capture.ok) return capture;
+
const existing = await findResumableSession(invitation.versionId, respondentUserId);
if (existing) return { ok: true, session: existing, resumed: true };
@@ -114,6 +159,9 @@ export async function createSessionFromInvitation(
select: { id: true, status: true, versionId: true },
});
await recordSessionCreated(created.id, { tx });
+ // Profile snapshot is captured once, on first start (skipped on resume above).
+ if (capture.values)
+ await writeProfileSnapshot(tx, created.id, respondentUserId, capture.values);
// Advance the invitation lifecycle on first start; a re-entry (already `started`) is a no-op.
if (invitation.status === 'registered') {
await tx.appQuestionnaireInvitation.update({
@@ -146,7 +194,9 @@ export async function createSessionForVersion(
return { ok: false, status: 404, code: 'NOT_FOUND', message: 'Questionnaire not found' };
}
// Direct (no-invitation) starts are only for the anonymous-mode surface; an
- // invitation-gated questionnaire requires the invitation path.
+ // invitation-gated questionnaire requires the invitation path. Because this surface is
+ // anonymous by definition, NO profile snapshot is ever captured here (F8.3 invariant) —
+ // only the invitation path (always non-anonymous) collects profile data.
if (!version.config?.anonymousMode) {
return {
ok: false,
diff --git a/app/api/v1/app/questionnaire-sessions/_lib/session-export.ts b/app/api/v1/app/questionnaire-sessions/_lib/session-export.ts
index 75733afd..364d1999 100644
--- a/app/api/v1/app/questionnaire-sessions/_lib/session-export.ts
+++ b/app/api/v1/app/questionnaire-sessions/_lib/session-export.ts
@@ -33,6 +33,10 @@ import {
buildSessionExportModel,
type SessionExportInput,
} from '@/lib/app/questionnaire/export/build-session-export-model';
+import {
+ asProfileValues,
+ type ProfileValues,
+} from '@/lib/app/questionnaire/profile/profile-values';
import type { SessionExportModel } from '@/lib/app/questionnaire/export/types';
/** Raw demo-client theme columns (or null when the questionnaire is unattributed). */
@@ -55,6 +59,8 @@ export interface LoadedSessionExport {
audience: AudienceShape | null;
anonymous: boolean;
respondentName: string | null;
+ /** Collected profile values, or null when anonymous / none collected (identifying). */
+ profile: ProfileValues | null;
completedAt: string | null;
theme: RawTheme;
status: SessionStatus;
@@ -112,6 +118,9 @@ export async function loadSessionExport(sessionId: string): Promise {
const result =
'invitationToken' in body
- ? await createSessionFromInvitation(body.invitationToken, respondentUserId)
+ ? await createSessionFromInvitation(
+ body.invitationToken,
+ respondentUserId,
+ body.profileValues
+ )
: await createSessionForVersion(body.versionId, respondentUserId);
if (!result.ok) {
diff --git a/components/admin/questionnaires/analytics/completion-funnel-panel.tsx b/components/admin/questionnaires/analytics/completion-funnel-panel.tsx
index 250ef8a7..21ff093f 100644
--- a/components/admin/questionnaires/analytics/completion-funnel-panel.tsx
+++ b/components/admin/questionnaires/analytics/completion-funnel-panel.tsx
@@ -10,6 +10,9 @@
*/
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+// Import the threshold from the pure `privacy` leaf, not the barrel — the barrel
+// re-exports the Prisma-coupled aggregators, which must not enter this client bundle.
+import { K_ANONYMITY_THRESHOLD } from '@/lib/app/questionnaire/analytics/privacy';
import type { CompletionFunnelResult, FunnelStage } from '@/lib/app/questionnaire/analytics';
function pct(n: number): string {
@@ -48,6 +51,15 @@ export function CompletionFunnelPanel({ data }: { data: CompletionFunnelResult |
return
Funnel data could not be loaded.
;
}
+ if (data.suppressed) {
+ return (
+
+ The completion funnel is hidden to protect respondent privacy — fewer than{' '}
+ {K_ANONYMITY_THRESHOLD} respondents in this window.
+
+ );
+ }
+
const invited = data.stages[0]?.count ?? 0;
return (
diff --git a/components/admin/questionnaires/analytics/cost-panel.tsx b/components/admin/questionnaires/analytics/cost-panel.tsx
index c01b6cfb..ca6847e2 100644
--- a/components/admin/questionnaires/analytics/cost-panel.tsx
+++ b/components/admin/questionnaires/analytics/cost-panel.tsx
@@ -28,6 +28,9 @@ import {
} from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { formatUsd } from '@/lib/utils/format-currency';
+// Import the threshold from the pure `privacy` leaf, not the barrel — the barrel
+// re-exports the Prisma-coupled aggregators, which must not enter this client bundle.
+import { K_ANONYMITY_THRESHOLD } from '@/lib/app/questionnaire/analytics/privacy';
import type { QuestionnaireCostResult } from '@/lib/app/questionnaire/analytics';
function StatCard({ label, value }: { label: string; value: string }) {
@@ -122,7 +125,13 @@ export function CostPanel({ data }: { data: QuestionnaireCostResult | null }) {
Top sessions by spend
- {data.topSessions.length === 0 ? (
+ {data.topSessionsSuppressed ? (
+
+ Per-session spend is hidden to protect respondent privacy — the questionnaire is
+ anonymous or has fewer than {K_ANONYMITY_THRESHOLD} sessions. Totals above are
+ unaffected.
+
+ ) : data.topSessions.length === 0 ? (
No respondent session spend yet.
) : (
diff --git a/components/admin/questionnaires/analytics/question-distribution-panel.tsx b/components/admin/questionnaires/analytics/question-distribution-panel.tsx
index 381fa3a2..811d70b2 100644
--- a/components/admin/questionnaires/analytics/question-distribution-panel.tsx
+++ b/components/admin/questionnaires/analytics/question-distribution-panel.tsx
@@ -13,6 +13,9 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { TagChip } from '@/components/admin/questionnaires/tag-chip';
import { QUESTION_TYPE_LABELS } from '@/lib/app/questionnaire/types';
+// Import the threshold from the pure `privacy` leaf, not the barrel — the barrel
+// re-exports the Prisma-coupled aggregators, which must not enter this client bundle.
+import { K_ANONYMITY_THRESHOLD } from '@/lib/app/questionnaire/analytics/privacy';
import type {
DistributionDetail,
QuestionDistribution,
@@ -52,6 +55,13 @@ function DetailBody({ detail }: { detail: DistributionDetail }) {
);
+ case 'suppressed':
+ return (
+
+ Hidden to protect respondent privacy (small sample).
+
+ );
+
case 'choice': {
const max = Math.max(1, ...detail.buckets.map((b) => b.count));
return (
@@ -178,6 +188,13 @@ export function QuestionDistributionPanel({ data }: { data: QuestionDistribution
{data.totalSessions} session{data.totalSessions === 1 ? '' : 's'} in range ·{' '}
{data.completedSessions} completed
+ {data.suppressed && (
+
+ Per-question answer detail is hidden to protect respondent privacy — fewer than{' '}
+ {K_ANONYMITY_THRESHOLD} sessions in this window. Response rates return once the sample
+ grows.
+
+ )}
{data.questions.length === 0 ? (
No questions match the current filter.
diff --git a/components/app/questionnaire/export/session-pdf-document.tsx b/components/app/questionnaire/export/session-pdf-document.tsx
index 29dab677..1fa4cc69 100644
--- a/components/app/questionnaire/export/session-pdf-document.tsx
+++ b/components/app/questionnaire/export/session-pdf-document.tsx
@@ -146,6 +146,12 @@ function formatConfidence(confidence: number | null): string | null {
return `${Math.round(confidence * 100)}% confidence`;
}
+/** Humanise a profile-field key slug (`job_title` → `Job title`) for the header label. */
+function humaniseKey(key: string): string {
+ const spaced = key.replace(/[_-]+/g, ' ').trim();
+ return spaced.length === 0 ? key : spaced.charAt(0).toUpperCase() + spaced.slice(1);
+}
+
/** One question slot — answered or "Not answered", with provenance/rationale/history. */
function SlotBlock({ slot }: { slot: PanelSlotView }) {
const confidence = formatConfidence(slot.confidence);
@@ -218,6 +224,15 @@ export function SessionPdfDocument({ model }: SessionPdfDocumentProps) {
Respondent:
{respondentLabel}
+ {/* Collected profile (F8.3) — only present for a non-anonymous session; the
+ model builder forces it to null in anonymous mode. */}
+ {model.profile &&
+ Object.entries(model.profile).map(([key, value]) => (
+
+ {`${humaniseKey(key)}: `}
+ {String(value)}
+
+ ))}
Completed:
{formatDate(model.completedAt)}
diff --git a/components/app/questionnaire/profile/profile-start-form.tsx b/components/app/questionnaire/profile/profile-start-form.tsx
new file mode 100644
index 00000000..afb127c7
--- /dev/null
+++ b/components/app/questionnaire/profile/profile-start-form.tsx
@@ -0,0 +1,171 @@
+'use client';
+
+/**
+ * Respondent profile form (F8.3) — collected at session start on the authenticated
+ * (non-anonymous) surface, before the chat begins. The fields are admin-authored
+ * (`profileFields`); this renders one input per field and POSTs the values into the
+ * session-create route, which writes the `AppRespondentProfileSnapshot` atomically and
+ * returns the new session id. On success we navigate to the chat.
+ *
+ * The anonymous surface never renders this — `loadStartContext` only returns
+ * `needs-profile` for a non-anonymous version with profile fields, so no PII is ever
+ * collected for an anonymous questionnaire.
+ */
+
+import { useState } from 'react';
+import { useForm, type Resolver } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useRouter } from 'next/navigation';
+import { z } from 'zod';
+
+import { apiClient, APIClientError } from '@/lib/api/client';
+import { API } from '@/lib/api/endpoints';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { FormError } from '@/components/forms/form-error';
+import { FieldHelp } from '@/components/ui/field-help';
+import type { ProfileFieldConfig } from '@/lib/app/questionnaire/types';
+
+type FormValues = Record;
+
+/** Build the client form schema. Values are strings (inputs); the server coerces/validates. */
+function buildFormSchema(fields: ProfileFieldConfig[]): z.ZodObject {
+ const shape: Record = {};
+ for (const field of fields) {
+ let base: z.ZodTypeAny;
+ switch (field.type) {
+ case 'email':
+ base = z.string().trim().email('Enter a valid email address');
+ break;
+ case 'number':
+ base = z
+ .string()
+ .trim()
+ .regex(/^-?\d+(\.\d+)?$/, 'Enter a number');
+ break;
+ case 'select':
+ base = z.string().min(1, 'Select an option');
+ break;
+ case 'text':
+ default:
+ base = z.string().trim().min(1, 'This field is required');
+ break;
+ }
+ // Optional fields accept an empty string (rendered blank, stripped before submit).
+ shape[field.key] = field.required ? base : z.union([base, z.literal('')]);
+ }
+ return z.object(shape);
+}
+
+export interface ProfileStartFormProps {
+ /** The invitation token the session is created from. */
+ invitationToken: string;
+ /** The admin-authored fields to collect, in order. */
+ fields: ProfileFieldConfig[];
+}
+
+export function ProfileStartForm({ invitationToken, fields }: ProfileStartFormProps) {
+ const router = useRouter();
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState(null);
+
+ const {
+ register,
+ handleSubmit,
+ formState: { errors },
+ } = useForm({
+ resolver: zodResolver(buildFormSchema(fields)) as Resolver,
+ mode: 'onTouched',
+ defaultValues: Object.fromEntries(fields.map((f) => [f.key, ''])),
+ });
+
+ const onSubmit = async (data: FormValues) => {
+ setSubmitting(true);
+ setError(null);
+
+ // Strip empty (untouched optional) values; the server validates the rest against
+ // the version's profile fields and coerces number/select types.
+ const profileValues: Record = {};
+ for (const [key, value] of Object.entries(data)) {
+ if (typeof value === 'string' && value.trim() !== '') profileValues[key] = value.trim();
+ }
+
+ try {
+ const result = await apiClient.post<{ session: { id: string } }>(
+ API.APP.QUESTIONNAIRE_SESSIONS.ROOT,
+ { body: { invitationToken, profileValues } }
+ );
+ router.push(`/questionnaires/${result.session.id}`);
+ } catch (err) {
+ setSubmitting(false);
+ setError(
+ err instanceof APIClientError
+ ? err.message
+ : 'We could not start your questionnaire. Please try again.'
+ );
+ }
+ };
+
+ return (
+
+
Before you begin
+
+ A few quick details to go with your responses.
+
+
+
+
+ );
+}
diff --git a/lib/app/questionnaire/analytics/cost.ts b/lib/app/questionnaire/analytics/cost.ts
index f0a95514..dbb2de2e 100644
--- a/lib/app/questionnaire/analytics/cost.ts
+++ b/lib/app/questionnaire/analytics/cost.ts
@@ -19,6 +19,7 @@
import { prisma } from '@/lib/db/client';
import { narrowToEnum, SESSION_STATUSES, type SessionStatus } from '@/lib/app/questionnaire/types';
+import { isCohortSuppressed } from '@/lib/app/questionnaire/analytics/privacy';
import type { AnalyticsScope } from '@/lib/app/questionnaire/analytics/query-schema';
import type {
CostCapabilityBucket,
@@ -76,11 +77,27 @@ export async function getQuestionnaireCostBreakdown(
const range = { from: scope.from.toISOString(), to: scope.to.toISOString() };
// Non-preview sessions for the version (all time — cost rows are date-filtered by
- // their own `createdAt`). Carries status + createdAt for the top-sessions table.
- const sessions = await prisma.appQuestionnaireSession.findMany({
- where: { versionId: scope.versionId, isPreview: false },
- select: { id: true, status: true, createdAt: true },
- });
+ // their own `createdAt`). Carries status + createdAt for the top-sessions table. The
+ // anonymous-mode flag is read alongside; the two reads are independent, so run them
+ // together.
+ const [sessions, config] = await Promise.all([
+ prisma.appQuestionnaireSession.findMany({
+ where: { versionId: scope.versionId, isPreview: false },
+ select: { id: true, status: true, createdAt: true },
+ }),
+ prisma.appQuestionnaireConfig.findUnique({
+ where: { versionId: scope.versionId },
+ select: { anonymousMode: true },
+ }),
+ ]);
+
+ // F8.3: the per-session spend table exposes session ids (a re-identification handle).
+ // Withhold it when the version is anonymous, or the cohort is below the k-anonymity
+ // threshold. Aggregate spend (total / by-capability / trend) carries no identity and
+ // is always returned.
+ const anonymous = config?.anonymousMode ?? false;
+ const topSessionsSuppressed = anonymous || isCohortSuppressed(sessions.length);
+
const sessionMeta = new Map(sessions.map((s) => [s.id, s]));
const sessionIds = sessions.map((s) => s.id);
const hasSessions = sessionIds.length > 0;
@@ -184,20 +201,22 @@ export async function getQuestionnaireCostBreakdown(
.map(([key, v]) => ({ key, label: labelFor(key), costUsd: v.costUsd, callCount: v.callCount }))
.sort((a, b) => b.costUsd - a.costUsd);
- const topSessions: SessionCostRow[] = [...sessionCost.entries()]
- .map(([sessionId, costUsd]) => {
- const meta = sessionMeta.get(sessionId);
- return {
- sessionId,
- status: meta
- ? narrowToEnum(meta.status, SESSION_STATUSES, 'active')
- : 'active',
- costUsd,
- createdAt: (meta?.createdAt ?? scope.from).toISOString(),
- };
- })
- .sort((a, b) => b.costUsd - a.costUsd)
- .slice(0, TOP_SESSIONS_LIMIT);
+ const topSessions: SessionCostRow[] = topSessionsSuppressed
+ ? []
+ : [...sessionCost.entries()]
+ .map(([sessionId, costUsd]) => {
+ const meta = sessionMeta.get(sessionId);
+ return {
+ sessionId,
+ status: meta
+ ? narrowToEnum(meta.status, SESSION_STATUSES, 'active')
+ : 'active',
+ costUsd,
+ createdAt: (meta?.createdAt ?? scope.from).toISOString(),
+ };
+ })
+ .sort((a, b) => b.costUsd - a.costUsd)
+ .slice(0, TOP_SESSIONS_LIMIT);
return {
versionId: scope.versionId,
@@ -208,5 +227,6 @@ export async function getQuestionnaireCostBreakdown(
byCapability: capabilityBuckets,
trend,
topSessions,
+ topSessionsSuppressed,
};
}
diff --git a/lib/app/questionnaire/analytics/distributions.ts b/lib/app/questionnaire/analytics/distributions.ts
index 66880f2b..e04c7269 100644
--- a/lib/app/questionnaire/analytics/distributions.ts
+++ b/lib/app/questionnaire/analytics/distributions.ts
@@ -18,11 +18,13 @@ import {
ANSWER_PROVENANCES,
QUESTION_TYPE_LABELS,
narrowToEnum,
+ TAG_COLORS,
type AnswerProvenance,
type QuestionType,
QUESTION_TYPES,
} from '@/lib/app/questionnaire/types';
import { typeConfigSchemaFor } from '@/lib/app/questionnaire/authoring/type-config-schema';
+import { isCohortSuppressed } from '@/lib/app/questionnaire/analytics/privacy';
import type { TagColor } from '@/lib/app/questionnaire/types';
import type { TagView } from '@/lib/app/questionnaire/views';
import type { AnalyticsScope } from '@/lib/app/questionnaire/analytics/query-schema';
@@ -292,20 +294,26 @@ export async function getQuestionDistributions(
bucket.provenance[label] += 1;
}
+ // F8.3: below the k-anonymity threshold a per-question distribution over a handful of
+ // sessions can re-identify a respondent's exact answer, so withhold all per-question
+ // detail and zero the counts. The question structure (prompt/type/section/tags) stays —
+ // only the response data is suppressed. An empty cohort (0) is not "suppressed".
+ const suppressed = isCohortSuppressed(totalSessions);
+
const questions: QuestionDistribution[] = slots.map((slot) => {
const type = narrowToEnum(slot.type, QUESTION_TYPES, 'free_text');
const bucket = byQuestion.get(slot.id)!;
- const answeredCount = bucket.values.length;
- const avgConfidence =
- bucket.confidences.length > 0
- ? bucket.confidences.reduce((a, b) => a + b, 0) / bucket.confidences.length
- : null;
const tags: TagView[] = slot.tags.map((t) => ({
id: t.tag.id,
label: t.tag.label,
- color: (t.tag.color as TagColor | null) ?? null,
+ // `color` is a free String? column constrained to the allowlist at write time;
+ // validate membership before narrowing rather than blindly casting the DB value.
+ color:
+ t.tag.color !== null && (TAG_COLORS as readonly string[]).includes(t.tag.color)
+ ? (t.tag.color as TagColor)
+ : null,
}));
- return {
+ const base = {
questionId: slot.id,
key: slot.key,
prompt: slot.prompt,
@@ -313,6 +321,25 @@ export async function getQuestionDistributions(
sectionTitle: slot.section.title,
required: slot.required,
tags,
+ };
+ if (suppressed) {
+ return {
+ ...base,
+ answeredCount: 0,
+ unansweredCount: 0,
+ responseRate: 0,
+ avgConfidence: null,
+ provenance: emptyProvenance(),
+ detail: { kind: 'suppressed' },
+ };
+ }
+ const answeredCount = bucket.values.length;
+ const avgConfidence =
+ bucket.confidences.length > 0
+ ? bucket.confidences.reduce((a, b) => a + b, 0) / bucket.confidences.length
+ : null;
+ return {
+ ...base,
answeredCount,
unansweredCount: Math.max(0, totalSessions - answeredCount),
responseRate: totalSessions > 0 ? answeredCount / totalSessions : 0,
@@ -322,7 +349,14 @@ export async function getQuestionDistributions(
};
});
- return { versionId: scope.versionId, range, totalSessions, completedSessions, questions };
+ return {
+ versionId: scope.versionId,
+ range,
+ totalSessions,
+ completedSessions,
+ suppressed,
+ questions,
+ };
}
/** Re-exported for the read view / tests that label types. */
diff --git a/lib/app/questionnaire/analytics/funnel.ts b/lib/app/questionnaire/analytics/funnel.ts
index bc9a0824..311ceb85 100644
--- a/lib/app/questionnaire/analytics/funnel.ts
+++ b/lib/app/questionnaire/analytics/funnel.ts
@@ -15,6 +15,7 @@
*/
import { prisma } from '@/lib/db/client';
+import { isCohortSuppressed } from '@/lib/app/questionnaire/analytics/privacy';
import type { AnalyticsScope } from '@/lib/app/questionnaire/analytics/query-schema';
import type {
CompletionFunnelResult,
@@ -108,10 +109,28 @@ export async function getCompletionFunnel(scope: AnalyticsScope): Promise 0 && total < K_ANONYMITY_THRESHOLD;
+}
diff --git a/lib/app/questionnaire/analytics/views.ts b/lib/app/questionnaire/analytics/views.ts
index faa5df40..3fd59ee8 100644
--- a/lib/app/questionnaire/analytics/views.ts
+++ b/lib/app/questionnaire/analytics/views.ts
@@ -61,6 +61,8 @@ export interface HistogramBin {
/**
* The type-appropriate shape of a question's answer distribution. `free_text`
* carries no value detail by design (PII / not meaningful as a distribution).
+ * `suppressed` carries none either — the whole surface is below the k-anonymity
+ * threshold (F8.3), so no per-question detail is emitted.
*/
export type DistributionDetail =
| { kind: 'choice'; buckets: ValueBucket[]; otherCount: number }
@@ -74,7 +76,8 @@ export type DistributionDetail =
falseCount: number;
}
| { kind: 'date'; buckets: { label: string; count: number }[] }
- | { kind: 'free_text' };
+ | { kind: 'free_text' }
+ | { kind: 'suppressed' };
/** One question's distribution over the non-preview sessions in scope. */
export interface QuestionDistribution {
@@ -104,6 +107,13 @@ export interface QuestionDistributionsResult {
totalSessions: number;
/** Of those, how many reached `completed`. */
completedSessions: number;
+ /**
+ * True when the cohort is non-empty but below the k-anonymity threshold
+ * ({@link K_ANONYMITY_THRESHOLD}): per-question `detail` is withheld (every question's
+ * `detail.kind` is `suppressed` and its counts are zeroed) so a tiny sample can't
+ * re-identify a respondent. F8.3, applied at the aggregator boundary.
+ */
+ suppressed: boolean;
questions: QuestionDistribution[];
}
@@ -137,6 +147,12 @@ export interface CompletionFunnelResult {
started: number;
completed: number;
};
+ /**
+ * True when the funnel cohort is non-empty but below the k-anonymity threshold
+ * ({@link K_ANONYMITY_THRESHOLD}): all stage counts and anonymous counts are zeroed so
+ * a tiny cohort can't re-identify who reached a stage. F8.3, at the aggregator.
+ */
+ suppressed: boolean;
}
/* ── Cost actuals ───────────────────────────────────────────────────────── */
@@ -175,6 +191,13 @@ export interface QuestionnaireCostResult {
byCapability: CostCapabilityBucket[];
/** Daily total spend across the window, ascending. */
trend: CostDayPoint[];
- /** Highest-spend respondent sessions, descending (capped). */
+ /** Highest-spend respondent sessions, descending (capped). Empty when suppressed. */
topSessions: SessionCostRow[];
+ /**
+ * True when the per-session spend table was withheld (F8.3): the version is anonymous
+ * (session ids are a re-identification handle), or the cohort is below the k-anonymity
+ * threshold ({@link K_ANONYMITY_THRESHOLD}). Aggregate spend (total / by-capability /
+ * trend) is always returned — it carries no per-respondent identity.
+ */
+ topSessionsSuppressed: boolean;
}
diff --git a/lib/app/questionnaire/chat/resumable-session.ts b/lib/app/questionnaire/chat/resumable-session.ts
new file mode 100644
index 00000000..3cf9aa2a
--- /dev/null
+++ b/lib/app/questionnaire/chat/resumable-session.ts
@@ -0,0 +1,39 @@
+/**
+ * The single definition of a respondent's "resumable" session (F8.3).
+ *
+ * A respondent resumes — rather than starts afresh — when they already have a
+ * non-preview, non-terminal session for the version. Both the create route
+ * (`questionnaire-sessions/_lib/create.ts`) and the pre-create resolver
+ * (`chat/start-context.ts`) must agree on this rule, or the start page would redirect
+ * to a session the create route wouldn't resume (or vice-versa). Keep it here so the
+ * rule lives in one place. Server-only.
+ */
+
+import { prisma } from '@/lib/db/client';
+
+/** A resumable session's identifying fields. */
+export interface ResumableSession {
+ id: string;
+ status: string;
+ versionId: string;
+}
+
+/**
+ * Find a respondent's existing non-terminal real session for a version (the resume
+ * target), or null if none. Most-recent first; preview sessions are excluded.
+ */
+export async function findResumableSession(
+ versionId: string,
+ respondentUserId: string
+): Promise {
+ return prisma.appQuestionnaireSession.findFirst({
+ where: {
+ versionId,
+ respondentUserId,
+ isPreview: false,
+ status: { in: ['active', 'paused'] },
+ },
+ orderBy: { createdAt: 'desc' },
+ select: { id: true, status: true, versionId: true },
+ });
+}
diff --git a/lib/app/questionnaire/chat/start-context.ts b/lib/app/questionnaire/chat/start-context.ts
new file mode 100644
index 00000000..af23f7a5
--- /dev/null
+++ b/lib/app/questionnaire/chat/start-context.ts
@@ -0,0 +1,59 @@
+/**
+ * Pre-create start context for the authenticated respondent surface (F8.3).
+ *
+ * The `start` server component normally creates/resumes a session and redirects straight
+ * to the chat. When the questionnaire collects a respondent profile (non-anonymous, with
+ * `profileFields`) and the caller has no session yet, we must instead show the profile
+ * form FIRST, then create the session with the collected values. This read-only resolver
+ * decides which of those three things to do, without writing anything.
+ *
+ * Only the invitation surface collects a profile — the version-direct surface is the
+ * anonymous "anyone may answer" path, which never collects one. Server-only.
+ */
+
+import { prisma } from '@/lib/db/client';
+import { hashInvitationToken } from '@/lib/app/questionnaire/invitations';
+import { parseProfileFields } from '@/lib/app/questionnaire/profile/profile-values';
+import { findResumableSession } from '@/lib/app/questionnaire/chat/resumable-session';
+import type { ProfileFieldConfig } from '@/lib/app/questionnaire/types';
+import type { AuthedSessionRequest } from '@/lib/app/questionnaire/chat/session-bootstrap';
+
+export type StartContext =
+ /** A resumable session already exists — skip the form and go straight to chat. */
+ | { kind: 'resume'; sessionId: string }
+ /** Non-anonymous version with profile fields and no session yet — collect first. */
+ | { kind: 'needs-profile'; profileFields: ProfileFieldConfig[] }
+ /** Nothing to collect — create/resume immediately (anonymous or no profile fields). */
+ | { kind: 'start-now' };
+
+/**
+ * Resolve whether the `start` page should collect a profile before creating the session.
+ * Any unresolvable / non-collecting case falls through to `start-now`, letting the create
+ * route own validation and error reporting (this resolver never blocks the happy path).
+ */
+export async function loadStartContext(
+ request: AuthedSessionRequest,
+ respondentUserId: string
+): Promise {
+ // Only the invitation surface is non-anonymous; a version-direct request is the
+ // anonymous path, which never collects a profile.
+ if (!('invitationToken' in request)) return { kind: 'start-now' };
+
+ const invitation = await prisma.appQuestionnaireInvitation.findUnique({
+ where: { tokenHash: hashInvitationToken(request.invitationToken) },
+ select: {
+ versionId: true,
+ version: { select: { config: { select: { anonymousMode: true, profileFields: true } } } },
+ },
+ });
+ if (!invitation) return { kind: 'start-now' };
+
+ const anonymous = invitation.version.config?.anonymousMode ?? false;
+ const profileFields = parseProfileFields(invitation.version.config?.profileFields);
+ if (anonymous || profileFields.length === 0) return { kind: 'start-now' };
+
+ const existing = await findResumableSession(invitation.versionId, respondentUserId);
+ if (existing) return { kind: 'resume', sessionId: existing.id };
+
+ return { kind: 'needs-profile', profileFields };
+}
diff --git a/lib/app/questionnaire/export/build-session-export-model.ts b/lib/app/questionnaire/export/build-session-export-model.ts
index 95465530..a63a6dc3 100644
--- a/lib/app/questionnaire/export/build-session-export-model.ts
+++ b/lib/app/questionnaire/export/build-session-export-model.ts
@@ -24,6 +24,7 @@ import {
type PanelSectionInput,
} from '@/lib/app/questionnaire/panel/answer-panel';
import { resolveTheme, type DemoClientTheme } from '@/lib/app/questionnaire/theming';
+import type { ProfileValues } from '@/lib/app/questionnaire/profile/profile-values';
import type { SessionExportModel } from '@/lib/app/questionnaire/export/types';
/** The plain inputs the DB seam hands the builder. */
@@ -37,6 +38,8 @@ export interface SessionExportInput {
anonymous: boolean;
/** Respondent display name (or null); dropped when `anonymous`. */
respondentName: string | null;
+ /** Collected profile values (or null); dropped when `anonymous`, same as the name. */
+ profile: ProfileValues | null;
/** ISO completion timestamp (or null when the session isn't completed). */
completedAt: string | null;
/** ISO generation timestamp (the seam stamps it; the builder has no clock). */
@@ -74,6 +77,8 @@ export function buildSessionExportModel(input: SessionExportInput): SessionExpor
const respondent =
input.anonymous || !input.respondentName ? null : { name: input.respondentName };
+ // Profile is identity — dropped in anonymous mode, same rule as the respondent name.
+ const profile = input.anonymous ? null : input.profile;
return {
questionnaireTitle: input.questionnaireTitle,
@@ -81,6 +86,7 @@ export function buildSessionExportModel(input: SessionExportInput): SessionExpor
goal: input.goal,
audienceSummary: summariseAudience(input.audience),
respondent,
+ profile,
anonymous: input.anonymous,
completedAt: input.completedAt,
generatedAt: input.generatedAt,
diff --git a/lib/app/questionnaire/export/results-loader.ts b/lib/app/questionnaire/export/results-loader.ts
index 1d24e3c7..6747b35a 100644
--- a/lib/app/questionnaire/export/results-loader.ts
+++ b/lib/app/questionnaire/export/results-loader.ts
@@ -25,6 +25,7 @@ import {
type SessionStatus,
} from '@/lib/app/questionnaire/types';
import type { PanelRefinementEntry } from '@/lib/app/questionnaire/panel/types';
+import { asProfileValues } from '@/lib/app/questionnaire/profile/profile-values';
import type { AnalyticsScope } from '@/lib/app/questionnaire/analytics';
import type {
ExportAnswer,
@@ -106,6 +107,9 @@ export async function loadResultsExport(scope: AnalyticsScope): Promise [a.questionKey, a]));
+ // Collected profile (F8.3) as a single JSON cell — empty in anonymous mode / when
+ // none were collected. Repeated per row like respondent_name (the spreadsheet view).
+ const profileCell = session.profile ? JSON.stringify(session.profile) : '';
for (const question of model.questions) {
const answer = answerByKey.get(question.key);
lines.push(
@@ -65,6 +69,7 @@ export function toResultsCsv(model: ResultsExportModel): string {
csvEscape(session.createdAt),
csvEscape(session.completedAt ?? ''),
csvEscape(session.respondentName ?? ''),
+ csvEscape(profileCell),
csvEscape(question.sectionTitle),
csvEscape(question.key),
csvEscape(question.prompt),
diff --git a/lib/app/questionnaire/export/results-types.ts b/lib/app/questionnaire/export/results-types.ts
index 46619cd1..c7532dc6 100644
--- a/lib/app/questionnaire/export/results-types.ts
+++ b/lib/app/questionnaire/export/results-types.ts
@@ -9,6 +9,9 @@
*
* Anonymous-mode contract (resolved in the loader, not here):
* - `respondentName` is null on every session.
+ * - `profile` is null on every session — the respondent profile snapshot (F8.3) is
+ * identifying data, so it never reaches an anonymous export (and is never even
+ * written for an anonymous session).
* - `turns` is `[]` on every session — raw respondent prose never reaches the export.
* Answer *values* are always present in both formats: anonymity is about not linking
* data to a person, not redacting the survey data itself (mirrors the PDF export).
@@ -17,6 +20,7 @@
import type { AnalyticsRange } from '@/lib/app/questionnaire/analytics';
import type { AnswerProvenance, QuestionType, SessionStatus } from '@/lib/app/questionnaire/types';
import type { PanelRefinementEntry } from '@/lib/app/questionnaire/panel/types';
+import type { ProfileValues } from '@/lib/app/questionnaire/profile/profile-values';
/** One question column in the export, in display order (section ordinal → slot ordinal). */
export interface ExportQuestion {
@@ -65,6 +69,12 @@ export interface ExportSession {
completedAt: string | null;
/** Null when the version is anonymous or the respondent is unknown. */
respondentName: string | null;
+ /**
+ * The profile-field values the respondent supplied at session start (keyed by field
+ * `key`), or null when the version is anonymous or none were collected. Identifying
+ * data — null on every session in an anonymous export.
+ */
+ profile: ProfileValues | null;
answers: ExportAnswer[];
/** Empty in anonymous mode. */
turns: ExportTurn[];
diff --git a/lib/app/questionnaire/export/types.ts b/lib/app/questionnaire/export/types.ts
index cd3d1e78..4f4fc9ec 100644
--- a/lib/app/questionnaire/export/types.ts
+++ b/lib/app/questionnaire/export/types.ts
@@ -20,6 +20,7 @@
import type { ResolvedTheme } from '@/lib/app/questionnaire/theming';
import type { PanelSectionView } from '@/lib/app/questionnaire/panel/types';
+import type { ProfileValues } from '@/lib/app/questionnaire/profile/profile-values';
/** The respondent identity shown in the PDF header (name only — never email). */
export interface ExportRespondent {
@@ -42,6 +43,12 @@ export interface SessionExportModel {
* "Anonymous respondent".
*/
respondent: ExportRespondent | null;
+ /**
+ * The profile-field values the respondent supplied at session start (keyed by field
+ * `key`), or null when anonymous OR none were collected. Identifying data — the
+ * builder forces this to null in anonymous mode, the same as `respondent`.
+ */
+ profile: ProfileValues | null;
/** True when the version is configured `anonymousMode` (drives the header copy). */
anonymous: boolean;
/** ISO timestamp the session completed, or null when not yet completed. */
diff --git a/lib/app/questionnaire/profile/profile-values.ts b/lib/app/questionnaire/profile/profile-values.ts
new file mode 100644
index 00000000..f01a6d98
--- /dev/null
+++ b/lib/app/questionnaire/profile/profile-values.ts
@@ -0,0 +1,111 @@
+/**
+ * Respondent profile-value collection — pure validation (F8.3).
+ *
+ * The admin authors a version's `profileFields` (name/email/role/custom) on the
+ * config; at session start a respondent supplies a value for each. This module turns
+ * the stored field config into a Zod schema and validates a respondent's raw
+ * submission against it, so the same rules guard both the client form
+ * (`components/app/questionnaire/profile/`) and the server capture seam
+ * (`questionnaire-sessions/_lib/create.ts`).
+ *
+ * Pure: Zod only, no Prisma / Next. The capture seam is the enforcing boundary —
+ * `.strict()` rejects unknown keys so a client can't smuggle arbitrary PII into the
+ * snapshot's `values`, and required fields must be present.
+ *
+ * ANONYMOUS MODE: never reaches here — the capture seam skips collection entirely when
+ * `anonymousMode = true`, so no profile values are validated, stored, or surfaced.
+ */
+
+import { z } from 'zod';
+
+import type { ProfileFieldConfig } from '@/lib/app/questionnaire/types';
+import { profileFieldSchema } from '@/lib/app/questionnaire/authoring/config-schema';
+
+/** The collected profile, keyed by field `key`. Values are strings or numbers. */
+export type ProfileValues = Record;
+
+/** Outcome of validating a raw submission against a version's `profileFields`. */
+export type ProfileValuesResult =
+ | { ok: true; values: ProfileValues }
+ | { ok: false; message: string };
+
+/**
+ * Cast a stored `AppRespondentProfileSnapshot.values` Json column back to
+ * {@link ProfileValues}. Returns null for an absent/non-object column. Mirrors the
+ * `asAudience` / `asRefinementHistory` JSON-column readers in the export seams — the
+ * value was validated on write, so this is a shape guard, not re-validation.
+ */
+export function asProfileValues(value: unknown): ProfileValues | null {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
+ return value as ProfileValues;
+}
+
+/**
+ * Parse the stored `AppQuestionnaireConfig.profileFields` Json column back to typed
+ * configs, dropping silently to `[]` on a malformed column (the read view already
+ * resolves defaults; the capture seam treats "no fields" as "nothing to collect").
+ */
+export function parseProfileFields(json: unknown): ProfileFieldConfig[] {
+ const parsed = z.array(profileFieldSchema).safeParse(json);
+ return parsed.success ? parsed.data : [];
+}
+
+/** Build the per-field value schema for one configured profile field. */
+function fieldValueSchema(field: ProfileFieldConfig): z.ZodTypeAny {
+ switch (field.type) {
+ case 'email':
+ return z.string().trim().email('Enter a valid email address').max(320);
+ case 'number':
+ return z.coerce.number().finite('Enter a valid number');
+ case 'select': {
+ const options = field.options ?? [];
+ // A select always has ≥1 option (config rule); guard the empty case so a
+ // malformed config degrades to a free string rather than throwing.
+ return options.length > 0 ? z.enum([...options]) : z.string().trim().min(1);
+ }
+ case 'text':
+ default:
+ return z.string().trim().min(1).max(2000);
+ }
+}
+
+/**
+ * Build a strict object schema from a version's profile fields: required fields are
+ * mandatory, optional ones may be omitted, and no unknown keys are allowed.
+ */
+export function buildProfileValuesSchema(fields: ProfileFieldConfig[]): z.ZodTypeAny {
+ const shape: Record = {};
+ for (const field of fields) {
+ const base = fieldValueSchema(field);
+ shape[field.key] = field.required ? base : base.optional();
+ }
+ return z.object(shape).strict();
+}
+
+/**
+ * Validate a raw respondent submission against a version's profile fields. Empty
+ * strings / nulls are treated as "not supplied" (dropped before validation), so an
+ * omitted optional field passes and a blank required field fails. Returns the cleaned
+ * {@link ProfileValues} on success, or the first issue's message on failure.
+ */
+export function validateProfileValues(
+ fields: ProfileFieldConfig[],
+ raw: unknown
+): ProfileValuesResult {
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
+ return { ok: false, message: 'Profile values must be an object' };
+ }
+
+ const cleaned: Record = {};
+ for (const [key, value] of Object.entries(raw)) {
+ if (value === null || value === undefined) continue;
+ if (typeof value === 'string' && value.trim() === '') continue;
+ cleaned[key] = value;
+ }
+
+ const parsed = buildProfileValuesSchema(fields).safeParse(cleaned);
+ if (!parsed.success) {
+ return { ok: false, message: parsed.error.issues[0]?.message ?? 'Invalid profile values' };
+ }
+ return { ok: true, values: parsed.data as ProfileValues };
+}
diff --git a/prisma/migrations/20260609062611_app_respondent_profile_snapshot/migration.sql b/prisma/migrations/20260609062611_app_respondent_profile_snapshot/migration.sql
new file mode 100644
index 00000000..e59a966a
--- /dev/null
+++ b/prisma/migrations/20260609062611_app_respondent_profile_snapshot/migration.sql
@@ -0,0 +1,33 @@
+-- F8.3: respondent profile snapshot (PII, 1:1 with a non-anonymous session).
+--
+-- The Prisma-generated diff also emitted phantom DROPs for four raw-SQL objects it
+-- cannot model (the pgvector HNSW / search indexes
+-- idx_ai_knowledge_chunk_search_vector, idx_knowledge_embedding,
+-- idx_message_embedding, idx_app_question_slot_embedding, and the
+-- ai_knowledge_chunk.searchVector default). Those were stripped — this migration
+-- only creates the new table. See the drift warnings in the schema + the
+-- app-migration create-only discipline.
+
+-- CreateTable
+CREATE TABLE "app_respondent_profile_snapshot" (
+ "id" TEXT NOT NULL,
+ "sessionId" TEXT NOT NULL,
+ "respondentUserId" TEXT,
+ "values" JSONB NOT NULL DEFAULT '{}',
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "app_respondent_profile_snapshot_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "app_respondent_profile_snapshot_sessionId_key" ON "app_respondent_profile_snapshot"("sessionId");
+
+-- CreateIndex
+CREATE INDEX "app_respondent_profile_snapshot_respondentUserId_idx" ON "app_respondent_profile_snapshot"("respondentUserId");
+
+-- AddForeignKey
+ALTER TABLE "app_respondent_profile_snapshot" ADD CONSTRAINT "app_respondent_profile_snapshot_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "app_questionnaire_session"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "app_respondent_profile_snapshot" ADD CONSTRAINT "app_respondent_profile_snapshot_respondentUserId_fkey" FOREIGN KEY ("respondentUserId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/prisma/schema/app-questionnaire.prisma b/prisma/schema/app-questionnaire.prisma
index 7c3831d6..d3f8a897 100644
--- a/prisma/schema/app-questionnaire.prisma
+++ b/prisma/schema/app-questionnaire.prisma
@@ -413,10 +413,11 @@ model AppQuestionnaireSession {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
- version AppQuestionnaireVersion @relation(fields: [versionId], references: [id], onDelete: Cascade)
- answers AppAnswerSlot[]
- events AppQuestionnaireSessionEvent[]
- turns AppQuestionnaireTurn[]
+ version AppQuestionnaireVersion @relation(fields: [versionId], references: [id], onDelete: Cascade)
+ answers AppAnswerSlot[]
+ events AppQuestionnaireSessionEvent[]
+ turns AppQuestionnaireTurn[]
+ profileSnapshot AppRespondentProfileSnapshot?
@@index([versionId])
@@index([versionId, isPreview])
@@ -431,6 +432,40 @@ model AppQuestionnaireSession {
@@map("app_questionnaire_session")
}
+/// The profile-field values a respondent supplied at session start (F8.3), 1:1 with a
+/// NON-anonymous session. PII by construction: `values` holds the answers to the
+/// admin-authored `AppQuestionnaireConfig.profileFields` (name/email/role/custom),
+/// keyed by field `key`.
+///
+/// ⚠️ ANONYMOUS-MODE INVARIANT ⚠️
+/// NO row is ever written for a session whose version config has
+/// `anonymousMode = true`. The capture seam (`questionnaire-sessions/_lib/create.ts`)
+/// skips the write entirely in anonymous mode — absence, not an empty row (the
+/// strongest, most testable guarantee). Read paths additionally omit the snapshot when
+/// anonymous, as defence in depth. See `.context/app/questionnaire/anonymous-mode.md`.
+///
+/// This is the FIRST questionnaire model with a modelled `User` FK — the deferred-UG-1
+/// "plain String, no @relation" posture used elsewhere is deliberately broken here
+/// because this row IS personal data and MUST cascade on erasure. `respondentUserId`
+/// is denormalised from the session so the cascade has a direct FK; it carries
+/// `onDelete: Cascade` so `prisma.user.delete()` (via `eraseUser()`) removes the
+/// snapshot natively — no erasure hook needed. The `session` FK also cascades, so
+/// deleting a session removes its snapshot.
+model AppRespondentProfileSnapshot {
+ id String @id @default(cuid())
+ sessionId String @unique
+ respondentUserId String? // denormalised from the session; null for no-login (which never writes a row anyway)
+ values Json @default("{}") // { [profileFieldKey]: string | number } — the collected PII
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ session AppQuestionnaireSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
+ user User? @relation(fields: [respondentUserId], references: [id], onDelete: Cascade)
+
+ @@index([respondentUserId])
+ @@map("app_respondent_profile_snapshot")
+}
+
/// One captured answer for a question slot within a session. F4.2 produces the
/// extraction INTENTS; F4.4 is the first writer (via the refine-answer route) and
/// owns the refinement update: the value/provenance change plus an appended
diff --git a/prisma/schema/auth.prisma b/prisma/schema/auth.prisma
index ccac42ea..9b7571eb 100644
--- a/prisma/schema/auth.prisma
+++ b/prisma/schema/auth.prisma
@@ -70,6 +70,9 @@ model User {
mcpApiKeys McpApiKey[] @relation("McpApiKeyCreator")
mcpExposedPrompts McpExposedPrompt[] @relation("McpPromptCreator")
+ // ConQuest app relations — personal data, cascades on erasure (F8.3).
+ appRespondentProfileSnapshots AppRespondentProfileSnapshot[]
+
@@unique([email])
@@index([role])
@@index([role, accountType])
diff --git a/tests/integration/api/v1/app/questionnaire-sessions/create-route.test.ts b/tests/integration/api/v1/app/questionnaire-sessions/create-route.test.ts
index ee90d69d..3cf27bed 100644
--- a/tests/integration/api/v1/app/questionnaire-sessions/create-route.test.ts
+++ b/tests/integration/api/v1/app/questionnaire-sessions/create-route.test.ts
@@ -115,7 +115,8 @@ describe('dispatch', () => {
expect(res.status).toBe(201);
expect(createMock.createSessionFromInvitation).toHaveBeenCalledWith(
'tok_abcdefghij',
- 'cmjbv4i3x00003wsloputgwul'
+ 'cmjbv4i3x00003wsloputgwul',
+ undefined // no profileValues on this body
);
expect(createMock.createSessionForVersion).not.toHaveBeenCalled();
const body = await res.json();
@@ -123,6 +124,19 @@ describe('dispatch', () => {
expect(body.meta.resumed).toBe(false);
});
+ it('forwards profileValues from an invitationToken body (F8.3)', async () => {
+ const res = await POST(
+ req({ invitationToken: 'tok_abcdefghij', profileValues: { team: 'Analytics' } }),
+ undefined
+ );
+ expect(res.status).toBe(201);
+ expect(createMock.createSessionFromInvitation).toHaveBeenCalledWith(
+ 'tok_abcdefghij',
+ 'cmjbv4i3x00003wsloputgwul',
+ { team: 'Analytics' }
+ );
+ });
+
it('routes a versionId body to createSessionForVersion', async () => {
const res = await POST(req({ versionId: 'v1' }), undefined);
expect(res.status).toBe(201);
diff --git a/tests/integration/api/v1/app/questionnaire-sessions/export-pdf-route.test.ts b/tests/integration/api/v1/app/questionnaire-sessions/export-pdf-route.test.ts
index 2618a9b9..c12e31bb 100644
--- a/tests/integration/api/v1/app/questionnaire-sessions/export-pdf-route.test.ts
+++ b/tests/integration/api/v1/app/questionnaire-sessions/export-pdf-route.test.ts
@@ -56,6 +56,7 @@ function model(over: Partial = {}): SessionExportModel {
audienceSummary: null,
respondent: { name: 'Ada' },
anonymous: false,
+ profile: null,
completedAt: '2026-06-01T10:00:00.000Z',
generatedAt: '2026-06-07T12:00:00.000Z',
theme: { ctaColor: '#000', accentColor: '#000', logoUrl: null, welcomeCopy: 'hi' },
diff --git a/tests/integration/api/v1/app/questionnaire-sessions/profile-snapshot.test.ts b/tests/integration/api/v1/app/questionnaire-sessions/profile-snapshot.test.ts
new file mode 100644
index 00000000..974b0853
--- /dev/null
+++ b/tests/integration/api/v1/app/questionnaire-sessions/profile-snapshot.test.ts
@@ -0,0 +1,225 @@
+/**
+ * Integration test: the F8.3 respondent profile-snapshot capture seam.
+ *
+ * The load-bearing anonymous-mode invariant: a snapshot is written ONLY for a
+ * non-anonymous session that supplied valid profile values; it is NEVER written for an
+ * anonymous session (no-login or authed-anonymous), and invalid/empty submissions are
+ * rejected or skipped. Prisma + the `recordSessionCreated` seam are mocked; `$transaction`
+ * runs its callback against a tx mock that records `appRespondentProfileSnapshot.create`.
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const mocks = vi.hoisted(() => {
+ const tx = {
+ appQuestionnaireSession: { create: vi.fn() },
+ appQuestionnaireInvitation: { update: vi.fn() },
+ appRespondentProfileSnapshot: { create: vi.fn() },
+ };
+ const prisma = {
+ $transaction: vi.fn((cb: (t: typeof tx) => unknown) => cb(tx)),
+ appQuestionnaireInvitation: { findUnique: vi.fn() },
+ appQuestionnaireVersion: { findUnique: vi.fn() },
+ appQuestionnaireSession: { findFirst: vi.fn() },
+ };
+ return { tx, prisma };
+});
+vi.mock('@/lib/db/client', () => ({ prisma: mocks.prisma }));
+
+const seamMock = vi.hoisted(() => ({ recordSessionCreated: vi.fn() }));
+vi.mock('@/app/api/v1/app/questionnaires/_lib/sessions', () => seamMock);
+
+import {
+ createAnonymousSession,
+ createSessionForVersion,
+ createSessionFromInvitation,
+} from '@/app/api/v1/app/questionnaire-sessions/_lib/create';
+
+type Mock = ReturnType;
+const USER = 'user-1';
+const NEW_SESSION = { id: 'sess-new', status: 'active', versionId: 'v1' };
+
+/** A profile field config (parsed from the JSON column at capture time). */
+const PROFILE_FIELDS = [
+ { key: 'team', label: 'Team', type: 'text', required: true },
+ {
+ key: 'seniority',
+ label: 'Seniority',
+ type: 'select',
+ required: false,
+ options: ['junior', 'senior'],
+ },
+];
+
+/** An invitation whose version collects a profile (non-anonymous) by default. */
+const invitation = (over: Record = {}) => ({
+ id: 'inv-1',
+ userId: USER,
+ status: 'registered',
+ versionId: 'v1',
+ version: {
+ status: 'launched',
+ config: { anonymousMode: false, profileFields: PROFILE_FIELDS },
+ ...((over.version as Record) ?? {}),
+ },
+ ...over,
+});
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ (mocks.tx.appQuestionnaireSession.create as Mock).mockResolvedValue(NEW_SESSION);
+ (mocks.tx.appQuestionnaireInvitation.update as Mock).mockResolvedValue({});
+ (mocks.tx.appRespondentProfileSnapshot.create as Mock).mockResolvedValue({});
+ (mocks.prisma.appQuestionnaireSession.findFirst as Mock).mockResolvedValue(null);
+ (seamMock.recordSessionCreated as Mock).mockResolvedValue(undefined);
+});
+
+describe('profile snapshot capture (non-anonymous invitation surface)', () => {
+ it('writes a snapshot with the validated values inside the create transaction', async () => {
+ (mocks.prisma.appQuestionnaireInvitation.findUnique as Mock).mockResolvedValue(invitation());
+
+ const result = await createSessionFromInvitation('tok', USER, {
+ team: 'Analytics',
+ seniority: 'senior',
+ });
+
+ expect(result).toMatchObject({ ok: true, resumed: false });
+ expect(mocks.tx.appRespondentProfileSnapshot.create).toHaveBeenCalledWith({
+ data: {
+ sessionId: 'sess-new',
+ respondentUserId: USER,
+ values: { team: 'Analytics', seniority: 'senior' },
+ },
+ });
+ });
+
+ it('rejects unknown profile keys with a 400 and writes nothing', async () => {
+ (mocks.prisma.appQuestionnaireInvitation.findUnique as Mock).mockResolvedValue(invitation());
+
+ const result = await createSessionFromInvitation('tok', USER, {
+ team: 'Analytics',
+ smuggled: 'pii@evil.com',
+ });
+
+ expect(result).toMatchObject({ ok: false, status: 400, code: 'INVALID_PROFILE' });
+ expect(mocks.tx.appQuestionnaireSession.create).not.toHaveBeenCalled();
+ expect(mocks.tx.appRespondentProfileSnapshot.create).not.toHaveBeenCalled();
+ });
+
+ it('rejects a missing required field with a 400', async () => {
+ (mocks.prisma.appQuestionnaireInvitation.findUnique as Mock).mockResolvedValue(invitation());
+
+ // `team` is required; only the optional field is supplied.
+ const result = await createSessionFromInvitation('tok', USER, { seniority: 'junior' });
+
+ expect(result).toMatchObject({ ok: false, status: 400, code: 'INVALID_PROFILE' });
+ expect(mocks.tx.appRespondentProfileSnapshot.create).not.toHaveBeenCalled();
+ });
+
+ it('writes no snapshot row when the submission is empty (no empty rows)', async () => {
+ (mocks.prisma.appQuestionnaireInvitation.findUnique as Mock).mockResolvedValue(
+ invitation({
+ version: { status: 'launched', config: { anonymousMode: false, profileFields: [] } },
+ })
+ );
+
+ const result = await createSessionFromInvitation('tok', USER, {});
+
+ expect(result).toMatchObject({ ok: true });
+ expect(mocks.tx.appQuestionnaireSession.create).toHaveBeenCalledTimes(1);
+ expect(mocks.tx.appRespondentProfileSnapshot.create).not.toHaveBeenCalled();
+ });
+
+ it('rejects with a 400 (and creates nothing) when a required field is configured but no profileValues are sent', async () => {
+ // The server is the enforcing boundary, not the form: an omitted payload is
+ // validated as an empty submission, so the required `team` field still rejects —
+ // a direct API caller can't bypass required collection by dropping the key.
+ (mocks.prisma.appQuestionnaireInvitation.findUnique as Mock).mockResolvedValue(invitation());
+
+ const result = await createSessionFromInvitation('tok', USER); // no profileValues arg
+
+ expect(result).toMatchObject({ ok: false, status: 400, code: 'INVALID_PROFILE' });
+ expect(mocks.tx.appQuestionnaireSession.create).not.toHaveBeenCalled();
+ expect(mocks.tx.appRespondentProfileSnapshot.create).not.toHaveBeenCalled();
+ });
+
+ it('creates the session with no snapshot when no profileValues are sent and all fields are optional', async () => {
+ // Only-optional fields + an omitted payload is a legitimate "nothing to capture":
+ // the session is created and no snapshot row is written.
+ (mocks.prisma.appQuestionnaireInvitation.findUnique as Mock).mockResolvedValue(
+ invitation({
+ version: {
+ status: 'launched',
+ config: {
+ anonymousMode: false,
+ profileFields: [
+ { key: 'seniority', label: 'Seniority', type: 'text', required: false },
+ ],
+ },
+ },
+ })
+ );
+
+ const result = await createSessionFromInvitation('tok', USER); // no profileValues arg
+
+ expect(result).toMatchObject({ ok: true });
+ expect(mocks.tx.appQuestionnaireSession.create).toHaveBeenCalledTimes(1);
+ expect(mocks.tx.appRespondentProfileSnapshot.create).not.toHaveBeenCalled();
+ });
+
+ it('does not capture a profile on resume (snapshot is written once, at first start)', async () => {
+ (mocks.prisma.appQuestionnaireInvitation.findUnique as Mock).mockResolvedValue(invitation());
+ (mocks.prisma.appQuestionnaireSession.findFirst as Mock).mockResolvedValue({
+ id: 'sess-existing',
+ status: 'paused',
+ versionId: 'v1',
+ });
+
+ const result = await createSessionFromInvitation('tok', USER, { team: 'Analytics' });
+
+ expect(result).toMatchObject({ ok: true, resumed: true });
+ expect(mocks.tx.appRespondentProfileSnapshot.create).not.toHaveBeenCalled();
+ });
+});
+
+describe('profile snapshot is NEVER written in anonymous mode (F8.3 invariant)', () => {
+ it('skips capture when the invitation version is anonymousMode, even with values supplied', async () => {
+ (mocks.prisma.appQuestionnaireInvitation.findUnique as Mock).mockResolvedValue(
+ invitation({
+ version: {
+ status: 'launched',
+ config: { anonymousMode: true, profileFields: PROFILE_FIELDS },
+ },
+ })
+ );
+
+ const result = await createSessionFromInvitation('tok', USER, { team: 'Analytics' });
+
+ expect(result).toMatchObject({ ok: true });
+ expect(mocks.tx.appRespondentProfileSnapshot.create).not.toHaveBeenCalled();
+ });
+
+ it('never captures a profile on the authed anonymous-direct surface', async () => {
+ (mocks.prisma.appQuestionnaireVersion.findUnique as Mock).mockResolvedValue({
+ id: 'v1',
+ status: 'launched',
+ config: { anonymousMode: true },
+ });
+
+ await createSessionForVersion('v1', USER);
+
+ expect(mocks.tx.appRespondentProfileSnapshot.create).not.toHaveBeenCalled();
+ });
+
+ it('never captures a profile on the no-login anonymous surface', async () => {
+ (mocks.prisma.appQuestionnaireVersion.findUnique as Mock).mockResolvedValue({
+ id: 'v1',
+ status: 'launched',
+ config: { anonymousMode: true },
+ });
+
+ await createAnonymousSession('v1');
+
+ expect(mocks.tx.appRespondentProfileSnapshot.create).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/integration/api/v1/app/questionnaire-sessions/session-export.test.ts b/tests/integration/api/v1/app/questionnaire-sessions/session-export.test.ts
index dfc06ba7..3a1a1b7a 100644
--- a/tests/integration/api/v1/app/questionnaire-sessions/session-export.test.ts
+++ b/tests/integration/api/v1/app/questionnaire-sessions/session-export.test.ts
@@ -265,6 +265,21 @@ describe('loadSessionExport', () => {
expect(findUser).not.toHaveBeenCalled();
});
+ it('surfaces the profile snapshot when not anonymous (F8.3)', async () => {
+ findSession.mockResolvedValue(row({ profileSnapshot: { values: { team: 'Analytics' } } }));
+ const loaded = await loadSessionExport('sess-1');
+ expect(loaded?.profile).toEqual({ team: 'Analytics' });
+ });
+
+ it('drops the profile snapshot entirely in anonymous mode (F8.3)', async () => {
+ findSession.mockResolvedValue(
+ rowWithVersion({ config: { anonymousMode: true } })
+ // even if a snapshot row somehow existed, anonymous output must omit it
+ );
+ const loaded = await loadSessionExport('sess-1');
+ expect(loaded?.profile).toBeNull();
+ });
+
it('defaults anonymous to false when the version has no config row', async () => {
findSession.mockResolvedValue(rowWithVersion({ config: null }));
const loaded = await loadSessionExport('sess-1');
@@ -319,6 +334,7 @@ describe('buildSessionExportPdfModel', () => {
audience: { description: 'New hires' },
anonymous: false,
respondentName: 'Ada Lovelace',
+ profile: null,
completedAt: '2026-06-02T10:30:00.000Z',
theme: {
ctaColor: '#111111',
diff --git a/tests/integration/api/v1/app/questionnaires/session-export-pdf-route.test.ts b/tests/integration/api/v1/app/questionnaires/session-export-pdf-route.test.ts
index 4d6297b7..3e5b9be2 100644
--- a/tests/integration/api/v1/app/questionnaires/session-export-pdf-route.test.ts
+++ b/tests/integration/api/v1/app/questionnaires/session-export-pdf-route.test.ts
@@ -55,6 +55,7 @@ function model(over: Partial = {}): SessionExportModel {
audienceSummary: null,
respondent: null,
anonymous: true,
+ profile: null,
completedAt: null,
generatedAt: '2026-06-07T12:00:00.000Z',
theme: { ctaColor: '#000', accentColor: '#000', logoUrl: null, welcomeCopy: 'hi' },
diff --git a/tests/integration/api/v1/app/questionnaires/version-export.test.ts b/tests/integration/api/v1/app/questionnaires/version-export.test.ts
index 12b2fb4c..acd8abc3 100644
--- a/tests/integration/api/v1/app/questionnaires/version-export.test.ts
+++ b/tests/integration/api/v1/app/questionnaires/version-export.test.ts
@@ -91,6 +91,7 @@ const MODEL: ResultsExportModel = {
createdAt: '2026-01-10T09:00:00.000Z',
completedAt: '2026-01-10T09:30:00.000Z',
respondentName: 'Ada',
+ profile: null,
answers: [
{
questionKey: 'role',
@@ -182,7 +183,7 @@ describe('GET versions/:vid/export', () => {
const text = await res.text();
const [header, firstRow] = text.split('\n');
expect(header).toBe(
- 'session_id,session_status,created_at,completed_at,respondent_name,section_title,question_key,question_prompt,question_type,answer_value,confidence,provenance_label'
+ 'session_id,session_status,created_at,completed_at,respondent_name,respondent_profile,section_title,question_key,question_prompt,question_type,answer_value,confidence,provenance_label'
);
expect(firstRow).toContain('s1,completed,');
expect(firstRow).toContain('Engineer');
diff --git a/tests/unit/app/(protected)/questionnaires/start/page.test.tsx b/tests/unit/app/(protected)/questionnaires/start/page.test.tsx
index 5ed3b560..55656362 100644
--- a/tests/unit/app/(protected)/questionnaires/start/page.test.tsx
+++ b/tests/unit/app/(protected)/questionnaires/start/page.test.tsx
@@ -64,11 +64,31 @@ vi.mock('@/lib/app/questionnaire/chat/session-bootstrap', () => ({
createOrResumeAuthedSession: vi.fn(),
}));
+/**
+ * Mock the F8.3 pre-create resolver — the page calls this (which hits Prisma)
+ * before bootstrap to decide whether to collect a profile first. Defaulted to
+ * `start-now` (the legacy straight-to-chat path); branch tests override it.
+ */
+vi.mock('@/lib/app/questionnaire/chat/start-context', () => ({
+ loadStartContext: vi.fn(),
+}));
+
+/**
+ * Mock the profile form — a client component the page renders for the
+ * `needs-profile` branch. We only assert it is reached, not its internals.
+ */
+vi.mock('@/components/app/questionnaire/profile/profile-start-form', () => ({
+ ProfileStartForm: vi.fn(({ invitationToken }: { invitationToken: string }) => (
+
+ )),
+}));
+
import StartQuestionnairePage, { metadata } from '@/app/(protected)/questionnaires/start/page';
import { getServerSession } from '@/lib/auth/utils';
import { clearInvalidSession } from '@/lib/auth/clear-session';
import { isLiveSessionsEnabled } from '@/lib/app/questionnaire/feature-flag';
import { createOrResumeAuthedSession } from '@/lib/app/questionnaire/chat/session-bootstrap';
+import { loadStartContext } from '@/lib/app/questionnaire/chat/start-context';
import { redirect } from 'next/navigation';
// ---------------------------------------------------------------------------
@@ -110,6 +130,7 @@ describe('StartQuestionnairePage', () => {
// Happy-path defaults
vi.mocked(isLiveSessionsEnabled).mockResolvedValue(true);
vi.mocked(getServerSession).mockResolvedValue(MOCK_SESSION);
+ vi.mocked(loadStartContext).mockResolvedValue({ kind: 'start-now' });
vi.mocked(createOrResumeAuthedSession).mockResolvedValue({
ok: true,
sessionId: 's1',
@@ -307,4 +328,63 @@ describe('StartQuestionnairePage', () => {
expect(screen.getByText(message)).toBeInTheDocument();
});
});
+
+ // -------------------------------------------------------------------------
+ // F8.3 pre-create profile resolution
+ // -------------------------------------------------------------------------
+
+ describe('profile resolution (F8.3)', () => {
+ it('redirects straight to the chat when a resumable session already exists', async () => {
+ // Arrange: resolver finds a non-terminal session — skip the form entirely
+ vi.mocked(loadStartContext).mockResolvedValue({ kind: 'resume', sessionId: 'resumed_1' });
+
+ // Act & Assert: page redirects without creating a new session
+ await expect(
+ StartQuestionnairePage({
+ searchParams: makeSearchParams({ invitationToken: 'tok_xyz' }),
+ })
+ ).rejects.toThrow('NEXT_REDIRECT:/questionnaires/resumed_1');
+ expect(redirect).toHaveBeenCalledWith('/questionnaires/resumed_1');
+ // Bootstrap is bypassed — the existing session is reused
+ expect(createOrResumeAuthedSession).not.toHaveBeenCalled();
+ });
+
+ it('renders the profile form when the version needs a profile and no session exists', async () => {
+ // Arrange: resolver asks for profile collection before session creation
+ vi.mocked(loadStartContext).mockResolvedValue({
+ kind: 'needs-profile',
+ profileFields: [{ key: 'full_name', label: 'Full name', type: 'text', required: true }],
+ });
+
+ // Act
+ const Component = await StartQuestionnairePage({
+ searchParams: makeSearchParams({ invitationToken: 'tok_xyz' }),
+ });
+ render(Component);
+
+ // Assert: the form is rendered with the invitation token; bootstrap is deferred
+ // to the form's submit (the page does not create the session itself here)
+ const form = screen.getByTestId('profile-start-form');
+ expect(form).toHaveAttribute('data-token', 'tok_xyz');
+ expect(createOrResumeAuthedSession).not.toHaveBeenCalled();
+ });
+
+ it('falls through to bootstrap for a needs-profile context on the versionId surface', async () => {
+ // Arrange: needs-profile but the request is versionId (no invitationToken) — the
+ // page guards the form render on `'invitationToken' in request`, so it must NOT
+ // render the form and instead create the session normally.
+ vi.mocked(loadStartContext).mockResolvedValue({
+ kind: 'needs-profile',
+ profileFields: [{ key: 'full_name', label: 'Full name', type: 'text', required: true }],
+ });
+
+ // Act & Assert: version-direct path creates the session despite needs-profile
+ await expect(
+ StartQuestionnairePage({
+ searchParams: makeSearchParams({ versionId: 'ver_abc' }),
+ })
+ ).rejects.toThrow('NEXT_REDIRECT:/questionnaires/s1');
+ expect(createOrResumeAuthedSession).toHaveBeenCalledWith({ versionId: 'ver_abc' });
+ });
+ });
});
diff --git a/tests/unit/app/questionnaire-sessions/pdf-response.test.ts b/tests/unit/app/questionnaire-sessions/pdf-response.test.ts
index 16ed8dad..87441522 100644
--- a/tests/unit/app/questionnaire-sessions/pdf-response.test.ts
+++ b/tests/unit/app/questionnaire-sessions/pdf-response.test.ts
@@ -23,6 +23,7 @@ function model(over: Partial = {}): SessionExportModel {
audienceSummary: null,
respondent: null,
anonymous: false,
+ profile: null,
completedAt: null,
generatedAt: '2026-06-07T12:00:00.000Z',
theme: { ctaColor: '#000', accentColor: '#000', logoUrl: null, welcomeCopy: '' },
diff --git a/tests/unit/app/questionnaire-sessions/render-session-pdf.test.tsx b/tests/unit/app/questionnaire-sessions/render-session-pdf.test.tsx
index 1682d120..e6684f8d 100644
--- a/tests/unit/app/questionnaire-sessions/render-session-pdf.test.tsx
+++ b/tests/unit/app/questionnaire-sessions/render-session-pdf.test.tsx
@@ -25,6 +25,7 @@ function model(
audience: { description: 'New hires' },
anonymous: false,
respondentName: 'Ada Lovelace',
+ profile: null,
completedAt: '2026-06-01T10:00:00.000Z',
generatedAt: '2026-06-07T12:00:00.000Z',
theme: null,
diff --git a/tests/unit/components/app/questionnaire/profile/profile-start-form.test.tsx b/tests/unit/components/app/questionnaire/profile/profile-start-form.test.tsx
new file mode 100644
index 00000000..af03cb74
--- /dev/null
+++ b/tests/unit/components/app/questionnaire/profile/profile-start-form.test.tsx
@@ -0,0 +1,589 @@
+/**
+ * ProfileStartForm Component Tests (F8.3)
+ *
+ * Tests the profile form rendered before a non-anonymous session starts.
+ * The form renders one input per admin-authored ProfileFieldConfig, validates
+ * with Zod, POSTs to the session-create route via apiClient, and navigates to
+ * the chat on success.
+ *
+ * Test Coverage:
+ * - Rendering: one input per field type (text/email/number/select) with labels
+ * - Rendering: required vs optional field markers
+ * - Rendering: select field renders all options
+ * - Zod validation: submitting with empty required field shows error, no API call
+ * - Zod validation: email type rejects non-email values
+ * - Zod validation: number type rejects non-numeric values
+ * - Happy path: valid submit calls apiClient.post with correct endpoint + payload + token
+ * - Happy path: on success, router.push navigates to /questionnaires/
+ * - API failure: APIClientError message shown, no navigation
+ * - API failure: unexpected error shows fallback message, no navigation
+ * - Loading state: button shows "Starting…" while submitting
+ * - Loading state: button disabled while submitting
+ *
+ * @see components/app/questionnaire/profile/profile-start-form.tsx
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { ProfileStartForm } from '@/components/app/questionnaire/profile/profile-start-form';
+import type { ProfileFieldConfig } from '@/lib/app/questionnaire/types';
+
+// ---------------------------------------------------------------------------
+// Module-level mocks
+// ---------------------------------------------------------------------------
+
+vi.mock('@/lib/api/client', () => ({
+ apiClient: {
+ post: vi.fn(),
+ },
+ APIClientError: class APIClientError extends Error {
+ code?: string;
+ status?: number;
+ details?: Record;
+ constructor(
+ message: string,
+ code?: string,
+ status?: number,
+ details?: Record
+ ) {
+ super(message);
+ this.name = 'APIClientError';
+ this.code = code;
+ this.status = status;
+ this.details = details;
+ }
+ },
+}));
+
+// next/navigation is globally mocked in tests/setup.ts; we override useRouter
+// per-describe to capture the push spy.
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/** A minimal set of fields covering all four types. */
+const MIXED_FIELDS: ProfileFieldConfig[] = [
+ { key: 'name', label: 'Full Name', type: 'text', required: true },
+ { key: 'email', label: 'Email', type: 'email', required: true },
+ { key: 'age', label: 'Age', type: 'number', required: false },
+ {
+ key: 'role',
+ label: 'Role',
+ type: 'select',
+ required: true,
+ options: ['Engineer', 'Designer', 'Manager'],
+ },
+];
+
+const INVITATION_TOKEN = 'tok_test_abc123';
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe('components/app/questionnaire/profile/profile-start-form', () => {
+ const mockPush = vi.fn();
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+
+ const { useRouter } = await import('next/navigation');
+ vi.mocked(useRouter).mockReturnValue({
+ push: mockPush,
+ replace: vi.fn(),
+ refresh: vi.fn(),
+ back: vi.fn(),
+ forward: vi.fn(),
+ prefetch: vi.fn(),
+ });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ // -------------------------------------------------------------------------
+ // Rendering
+ // -------------------------------------------------------------------------
+
+ describe('rendering', () => {
+ it('renders one labelled input for each text/email/number field', () => {
+ // Arrange & Act
+ const { container } = render(
+
+ );
+
+ // Assert: one input element per non-select field keyed by the id the component assigns
+ // (id="profile-{key}"). We query by id because the Label wraps a FieldHelp button whose
+ // aria-label also matches the label text, so getByLabelText would find multiple elements.
+ expect(container.querySelector('#profile-name')).toBeInTheDocument();
+ expect(container.querySelector('#profile-email')).toBeInTheDocument();
+ expect(container.querySelector('#profile-age')).toBeInTheDocument();
+ });
+
+ it('renders the correct input type for email fields', () => {
+ // Arrange & Act
+ const { container } = render(
+
+ );
+
+ // Assert: the email input has type="email"
+ const emailInput = container.querySelector('#profile-email');
+ expect(emailInput).toHaveAttribute('type', 'email');
+ });
+
+ it('renders the correct input type for number fields', () => {
+ // Arrange & Act
+ const { container } = render(
+
+ );
+
+ // Assert: the number input has type="number"
+ const ageInput = container.querySelector('#profile-age');
+ expect(ageInput).toHaveAttribute('type', 'number');
+ });
+
+ it('renders a