Skip to content
Merged
24 changes: 13 additions & 11 deletions .context/app/planning/development-plan.md

Large diffs are not rendered by default.

7 changes: 5 additions & 2 deletions .context/app/planning/features/f3.2.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,5 +179,8 @@ the development-plan F3.2 status to done and add a decisions-log entry.

`started`/`completed` transitions (P6/P7 sessions). Demo-client invitation branding +
`demoClientId` denormalisation + themed email (**F3.4**). Pre-launch cost estimation
(**F3.3**). Claim-an-invite-via-existing-login (deferred; returns `409 ACCOUNT_EXISTS`).
Anonymous-mode session entry (P6/P7). `questionnaireId` denormalisation on the invitation.
(**F3.3**). ~~Claim-an-invite-via-existing-login (deferred; returns `409 ACCOUNT_EXISTS`).~~
**Closed 2026-06-07** (deferred-gaps audit, Item 3): an already-registered email now
claims the invitation by signing in (verified via `signInEmail`, `401` on a wrong
password), bound to the existing account; `metadata` reports `accountExists` to drive the
form branch. Anonymous-mode session entry (P6/P7). `questionnaireId` denormalisation on the invitation.
3 changes: 3 additions & 0 deletions .context/app/planning/features/f4.3.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ preview route returns findings and writes nothing.
windowed by `windowN`) and `phase:'completion-sweep'` (= sweep_only) from day one.
`every_n_turns` (a pure cost knob) waits for F4.6's real turn loop, where it's an
additive field + a scheduler branch — cheap to add, expensive to guess now.
**Landed 2026-06-07** (deferred-gaps audit, as predicted): the additive
`contradictionEveryNTurns` config column + an optional `cadence` arg on
`shouldRunDetection`, consumed by the F6.1 orchestrator (skips off-boundary turns).
- **Surface, never overwrite.** The capability returns `ContradictionFinding[]`
(which slots conflict, why, a severity, a `confidence`, and — under `probe` — a
`suggestedProbe`). It writes nothing. F4.4 acts on a confirmed conflict; the
Expand Down
15 changes: 11 additions & 4 deletions .context/app/planning/features/f6.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,17 @@ event) and of `AppQuestionnaireTurn` (firing `AppAnswerSlot.lastUpdatedTurnId`).

## Deferred / follow-ups

- **Attachment input** (extraction over an uploaded image/doc) — needs extending the F4.2
extraction capability to accept content parts; tracked for F6.1 PR8 / a follow-up.
- **Full refinementHistory append** on refine persistence (PR4 persists the corrected value
via the upsert seam; the history append through `persistRefinement` is a follow-up).
- ~~**Attachment input** (extraction over an uploaded image/doc) — needs extending the F4.2
extraction capability to accept content parts; tracked for F6.1 PR8 / a follow-up.~~
**Closed 2026-06-07** (deferred-gaps audit, Item 5): the answer-extractor capability accepts
`attachments` (content parts + a model-capability gate), the `/messages` route + orchestrator
thread them, and the chat surface has a flag-gated affordance
(`APP_QUESTIONNAIRES_ATTACHMENT_INPUT_ENABLED`, seed 024).
- ~~**Full refinementHistory append** on refine persistence (PR4 persists the corrected value
via the upsert seam; the history append through `persistRefinement` is a follow-up).~~
**Closed 2026-06-07** (deferred-gaps audit, Item 2): `persistTurn` now takes the full F4.4
path (`loadAnswerSlot` → `applyRefinement` → `persistRefinement`), so live sessions append
to `refinementHistory`.
- **Aggregate per-turn token usage** on the `done` event (the capabilities log their own
`AiCostLog` rows; the turn record carries summed offer cost only).
- **Admin-side anonymous redaction** (`anonId`, redacted session views) — a later phase.
Expand Down
4 changes: 3 additions & 1 deletion .context/app/planning/features/f7.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@ branding, and protect the sales-critical happy path with an end-to-end test.
- **F7.3** — session lifecycle UX (pause/resume controls, completion-offer accept flow, explicit
cost-cap surfacing beyond the terminal panel).
- **F7.4** — PDF export.
- **Attachment input** — blocked on an F4.2 extension.
- ~~**Attachment input** — blocked on an F4.2 extension.~~ **Closed 2026-06-07** (deferred-gaps
audit, Item 5): the F4.2 capability now accepts content parts and the chat composer has a
flag-gated attachment affordance (`APP_QUESTIONNAIRES_ATTACHMENT_INPUT_ENABLED`).
- **Authenticated e2e** — needs a login storage-state fixture (`globalSetup`); tracked in the e2e README.

## Tests
Expand Down
7 changes: 5 additions & 2 deletions .context/app/questionnaire/answer-refinement.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,11 @@ write seam, keeping `lib/app/questionnaire/refinement/**` Prisma-free):
answer (the "seed then refine" step), keyed on `@@unique([sessionId, questionSlotId])`.
- `loadAnswerSlot(sessionId, questionSlotId)` — shape a row for `applyRefinement`,
narrowing the stored `provenanceLabel` to the enum.
- `persistRefinement(rowId, refined)` — write `value`, `provenanceLabel`, and the
extended `refinementHistory`, stamping `createdAt` on any unstamped entry.
- `persistRefinement(rowId, refined)` — write `value`, `provenanceLabel`, `confidence`,
and the extended `refinementHistory`, stamping `createdAt` on any unstamped entry. The
refinement's `confidence` (from the decision) **replaces** the slot's prior score — a
refine can raise or lower it, since improving a low-confidence capture is the point of
refining; `applyRefinement` carries `decision.confidence` onto `RefinedSlotState`.

## Persistence foundation (the F4.6 slice F4.4 introduces)

Expand Down
27 changes: 14 additions & 13 deletions .context/app/questionnaire/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,20 @@ and stores them; the consumers land later (see _Who consumes it_).
like goal/audience and the section graph. One typed column per setting plus a
single JSON column for the profile fields:

| Setting | Column | Type | Default |
| -------------------------------- | ------------------------ | ---------------------- | ----------------- |
| Question selection strategy | `selectionStrategy` | String (enum) | `'sequential'` |
| Completion: min questions | `minQuestionsAnswered` | Int | `0` |
| Completion: coverage threshold | `coverageThreshold` | Float (0–1) | `1.0` |
| Cost budget (USD / session) | `costBudgetUsd` | Float? (null = no cap) | `null` |
| Per-session question cap | `maxQuestionsPerSession` | Int? (null = no cap) | `null` |
| Voice input | `voiceEnabled` | Boolean | `false` |
| Contradiction-detection mode | `contradictionMode` | String (enum) | `'off'` |
| Contradiction look-back window N | `contradictionWindowN` | Int | `0` |
| Anonymous mode | `anonymousMode` | Boolean | `false` |
| Session-start profile fields | `profileFields` | Json (array) | `[]` |
| Answer panel scope | `answerSlotPanelScope` | String (enum) | `'full_progress'` |
| Setting | Column | Type | Default |
| ------------------------------------- | -------------------------- | ---------------------- | ----------------- |
| Question selection strategy | `selectionStrategy` | String (enum) | `'sequential'` |
| Completion: min questions | `minQuestionsAnswered` | Int | `0` |
| Completion: coverage threshold | `coverageThreshold` | Float (0–1) | `1.0` |
| Cost budget (USD / session) | `costBudgetUsd` | Float? (null = no cap) | `null` |
| Per-session question cap | `maxQuestionsPerSession` | Int? (null = no cap) | `null` |
| Voice input | `voiceEnabled` | Boolean | `false` |
| Contradiction-detection mode | `contradictionMode` | String (enum) | `'off'` |
| Contradiction look-back window N | `contradictionWindowN` | Int | `0` |
| Contradiction cadence (every N turns) | `contradictionEveryNTurns` | Int | `1` |
| Anonymous mode | `anonymousMode` | Boolean | `false` |
| Session-start profile fields | `profileFields` | Json (array) | `[]` |
| Answer panel scope | `answerSlotPanelScope` | String (enum) | `'full_progress'` |

The enums are `const` tuples in `lib/app/questionnaire/types.ts` (single source of
truth — the Zod schema, the read-view narrowing, and the editor's `<Select>`
Expand Down
15 changes: 10 additions & 5 deletions .context/app/questionnaire/contradiction-detection.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,13 @@ will drive.
(or all when `windowN <= 0`) — covers the prose's _every_turn_.
- `phase: 'completion-sweep'` → run once before submit, comparing **all** answers
— covers the prose's _sweep_only_.
- The prose's _every_n_turns_ (a pure cost-tuning interval) is **deferred to
F4.6**, where the real turn loop exists; it becomes an additive config field +
a scheduler branch then, with no rework here.
- The prose's _every_n_turns_ (a pure cost-tuning interval) **landed 2026-06-07**
(deferred-gaps audit): the additive config column `contradictionEveryNTurns`
(`Int @default(1)`, 1 = every turn) + an optional `cadence` arg on
`shouldRunDetection(mode, windowN, phase, { everyNTurns, turnIndex })`. For
`phase: 'turn'`, detection runs only when `turnIndex % everyNTurns === 0` (the
orchestrator passes the zero-based `selectionRound`); the completion sweep ignores
cadence (the final gate never skips).

The natural high-value default falls out for free: `probe` + a completion sweep
catches every conflict with one end-of-session LLM call; per-turn detection is the
Expand Down Expand Up @@ -82,8 +86,9 @@ contradiction/
≡ `[b,a]`, keep highest confidence); clamp severity; mode-shape (`flag` strips any
probe; a `probe` finding with a missing/blank probe is _downgrade-kept_ without one,
not dropped — the conflict still stands).
- **`shouldRunDetection(mode, windowN, phase)`** — the pure scheduler (see cadence
above). Lives in the core so it's zero-mock unit-testable and reusable by F4.6.
- **`shouldRunDetection(mode, windowN, phase, cadence?)`** — the pure scheduler (see
cadence above). The optional `cadence` (`{ everyNTurns, turnIndex }`) skips off-boundary
turns. Lives in the core so it's zero-mock unit-testable; the live orchestrator consumes it.

### `normalizeContradictionFindings` outcomes

Expand Down
30 changes: 28 additions & 2 deletions .context/app/questionnaire/demo-clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,13 +115,39 @@ a typed-confirmation guard and an anonymous-mode refusal. See
while attributed).
- Both forms carry an **"Invitation branding"** fieldset (F3.4): CTA colour, accent
colour, logo URL, welcome copy — each optional with a `<FieldHelp>`; blank = the
Sunrise default.
Sunrise default. The edit form shows a **live `<DemoClientThemePreview>`** under the
fieldset (valid inputs only — a half-typed hex shows the default, not a broken
swatch).
- **Brand preview (`<DemoClientThemePreview>`).** Surfaces the configured brand back
to the admin — the gap that a client could set four theme fields and see nothing.
Reuses `resolveTheme()` and the same escaped `--app-logo-url` background as
`BrandThemeProvider` (never a raw `<img src>`). Two modes: **compact** on the list's
_Branding_ column (a swatch/thumbnail only for fields actually set; "Default" when
none) and **full** on the detail page / live form preview (the resolved brand the
respondent sees, defaults filled).
- The questionnaire detail page (`/admin/questionnaires/:id`) carries the
attribution `<DemoClientAssign>` picker (active clients + the current one).
attribution `<DemoClientAssign>` picker (active clients + the current one) and a
**`<CloneForClientDialog>`** "Clone for client" action (below).

Nav entry registered via `initAppNav()` (seam 4 — no sidebar edit). The admin shell
itself is **not** themed (that's for the end-user surface in P7).

## Clone for client

`POST /api/v1/app/questionnaires/:id/clone-for-client` `{ targetDemoClientId, nameSuffix? }`
(DEMO-ONLY) duplicates a questionnaire's **current** version (launched if present, else
the highest-numbered) into a brand-new questionnaire as a fresh `draft` v1, attributed to
the chosen demo client (`null` = a generic, unattributed copy) — so the same questionnaire
is re-usable for the next prospect. The structural copy (config + sections/slots + tag
vocabulary + assignments) is single-sourced with the F2.1 version-fork via
`_lib/copy-version-graph.ts` (`copyVersionGraph`); goal/audience are copied onto the new
v1; the newest source-document row is copied as provenance. **Not** copied: sessions,
invitations, evaluation runs, extraction-change records (a clone starts fresh). The new
title is `"<source title> — <suffix>"`, the suffix defaulting to the client name (or
"Copy"). Admin-only, flag-gated, audited as `questionnaire.clone_for_client`. _(Built
2026-06-07, deferred-gaps audit Item 4 — the relocated P2.5 clone-for-client, unblocked
once F2.2 tags + F3.1 config existed.)_

## Fork guidance

Everything demo-only is grep-isolated under the `DEMO-ONLY` marker:
Expand Down
22 changes: 14 additions & 8 deletions .context/app/questionnaire/invitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,14 +100,20 @@ the request — the response is a per-recipient result array (`sent`/`skipped`/`

Public, token-gated (F3.2 PR2 — no auth guard, rate-limited):

| Route | Method | Purpose |
| ---------------------------------- | ------ | ------------------------------------------------------------------------------ |
| `/api/v1/app/invitations/metadata` | GET | Validate token → `{ questionnaireTitle, inviteeName, status }`, mark `opened`. |
| `/api/v1/app/invitations/accept` | POST | Register (better-auth `signUpEmail`) + bind `userId` → `registered`. |

Accept reuses the platform `accept-invite` machinery (sign-up → set `emailVerified`
→ sign-in → forward Set-Cookie for auto-login). An already-registered email returns
`409 ACCOUNT_EXISTS` (claim-via-login deferred to P7).
| Route | Method | Purpose |
| ---------------------------------- | ------ | ---------------------------------------------------------------------------------------------------- |
| `/api/v1/app/invitations/metadata` | GET | Validate token → `{ questionnaireTitle, inviteeName, status, accountExists }`, mark `opened`. |
| `/api/v1/app/invitations/accept` | POST | New email: register (`signUpEmail`) + bind. Existing email: sign-in-and-claim + bind → `registered`. |

Accept reuses the platform `accept-invite` machinery (set `emailVerified` → sign-in →
forward Set-Cookie for auto-login). **A fresh email** registers a new account. **An
already-registered email claims the invitation by signing in** — the supplied password
is verified via `signInEmail` (a wrong one is `401 INVALID_CREDENTIALS`, binding
nothing) and the invitation is bound to that existing account; binding happens _after_
sign-in so a failed credential never half-registers. The metadata route reports
`accountExists` so the landing form asks for the existing password ("sign in to claim")
instead of offering to set a new one. _(Closed 2026-06-07, deferred-gaps audit Item 3 —
was the P7-deferred `409 ACCOUNT_EXISTS` dead-end.)_

## Admin UI

Expand Down
25 changes: 24 additions & 1 deletion .context/app/questionnaire/per-turn-orchestrator.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,12 @@ question prompts are deterministic).
cost; the route delegates with `yield*`.
- **Persistence** `_lib/turn-run.ts` (`persistTurn`) writes answer side-effects through the
F4.4 slot seam, then `recordTurn` (firing `lastUpdatedTurnId`). A post-response write
failure is logged, not retro-failed onto the streamed reply.
failure is logged, not retro-failed onto the streamed reply. Refinements take the F4.4
path in full — `loadAnswerSlot` → `applyRefinement` → `persistRefinement` — so a live
session **appends to `refinementHistory`** (the corrected value plus the pre-change
value/provenance/source) and **updates the slot's `confidence`** to the refiner's
certainty, not just the new value. A refinement targeting a slot with no
captured answer (shouldn't happen) falls back to a plain `refined`-provenance upsert.

## Routes & access

Expand Down Expand Up @@ -109,6 +114,19 @@ invariant:** no audio bytes or transcript are persisted — the only happy-path
row. The client sends the returned transcript through the normal text `/messages` path, so P7 can
wire Sunrise's `<MicButton>` (which expects an endpoint returning `{ text }`) at it verbatim.

### Attachment input

The `/messages` body accepts an optional `attachments` array (the platform `chatAttachmentSchema`
— up to 10 files, ~5 MB each / ~25 MB combined; images + PDF/DOCX/text). When the attachment
sub-flag is on, the route threads them onto `TurnState.attachments`; the extraction invoker forwards
them to the answer-extractor capability, whose prompt builder turns the user turn into multimodal
content parts (`text` + one `image`/`document` part per file — the same conversion as the platform
chat message builder) so the model reads the file alongside the message. Before the LLM call the
capability runs `assertModelSupportsAttachments(provider, model, [vision?|documents?])` and returns a
typed `attachments_not_supported` error if the resolved model lacks the modality (no silent text-only
extraction that would drop the attached answer). The `redactProvenance` row records only an
`attachmentCount`, never the bytes.

### No-login anonymous tokens

`_lib/session-access-token.ts` mints/verifies a stateless HMAC token (`{ sessionId, expiresAt }`
Expand All @@ -131,6 +149,11 @@ existing precedent is the embed widget (`lib/embed/auth.ts`).
useful once the respondent can send it through the live `/messages` loop (with live-sessions off
that route 404s, so transcription would be a dead but still-paid call). Off → the transcribe
route 404s before auth (`withVoiceInputEnabled`).
- Attachment input has its own dark-launch sub-flag `APP_QUESTIONNAIRES_ATTACHMENT_INPUT_ENABLED`
(seed 024, off by default), also ANDed with master + live-sessions (`isAttachmentInputEnabled`).
Unlike voice it doesn't gate a dedicated route — the chat surface hides the affordance and the
`/messages` route **ignores** any attachments a client sends while it's off, so the paid
multimodal path stays shut.

## See also

Expand Down
10 changes: 8 additions & 2 deletions app/(protected)/questionnaires/[sessionId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import { notFound } from 'next/navigation';
import { getServerSession } from '@/lib/auth/utils';
import { clearInvalidSession } from '@/lib/auth/clear-session';
import { prisma } from '@/lib/db/client';
import { isLiveSessionsEnabled, isVoiceInputEnabled } from '@/lib/app/questionnaire/feature-flag';
import {
isAttachmentInputEnabled,
isLiveSessionsEnabled,
isVoiceInputEnabled,
} from '@/lib/app/questionnaire/feature-flag';
import { SessionWorkspace } from '@/components/app/questionnaire/session-workspace';
import { BrandThemeProvider } from '@/components/app/questionnaire/chat/brand-theme-provider';
import { buildWelcomeTurns } from '@/lib/app/questionnaire/chat/greeting';
Expand Down Expand Up @@ -82,8 +86,9 @@ export default async function QuestionnaireSessionPage({
// panel + lifecycle status are SSR-seeded here (the user is already verified as owner),
// so they paint with no fetch flash; the live updates after each turn come from the
// client hooks.
const [voiceInputEnabled, theme, panel, status] = await Promise.all([
const [voiceInputEnabled, attachmentInputEnabled, theme, panel, status] = await Promise.all([
isVoiceInputEnabled(),
isAttachmentInputEnabled(),
resolveThemeForSession(sessionId),
loadAnswerPanelState(sessionId),
loadSessionStatus(sessionId),
Expand All @@ -100,6 +105,7 @@ export default async function QuestionnaireSessionPage({
initialPanel={panel?.view}
initialStatusView={status?.view}
voiceInputEnabled={voiceInputEnabled}
attachmentInputEnabled={attachmentInputEnabled}
/>
</BrandThemeProvider>
</div>
Expand Down
Loading