Skip to content

feat(questionnaires): F8.3 anonymous-mode hardening + respondent profile#55

Merged
JohnD-EE merged 1 commit into
mainfrom
feat/F8.3-anonymous-mode-hardening
Jun 9, 2026
Merged

feat(questionnaires): F8.3 anonymous-mode hardening + respondent profile#55
JohnD-EE merged 1 commit into
mainfrom
feat/F8.3-anonymous-mode-hardening

Conversation

@JohnD-EE

@JohnD-EE JohnD-EE commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Turns anonymousMode from a loose "open / no-invitation" flag into a real PII contract honoured at every data boundary — and adds the previously-unbuilt respondent profile collection capability so there's a snapshot for that contract to gate. Gated by APP_QUESTIONNAIRES_ENABLED.

The canonical contract (invariant, per-surface gate table, snapshot rule, k-anonymity, erasure cascade) lives in .context/app/questionnaire/anonymous-mode.md.

What changed

Model + erasure

  • New AppRespondentProfileSnapshot model (migration 20260609062611_app_respondent_profile_snapshot).
  • Both the User FK and the session FK are onDelete: CascadeeraseUser() removes snapshots via the native cascade, no cleanup hook. Schema test asserts both FKs cascade (the GDPR contract).

Capture seam (D1 — no row, not an empty row)

  • createSessionFromInvitation validates and writes the snapshot only on the non-anonymous invitation path with profile values present.
  • Anonymous, version-direct, and no-login paths never capture → anonymousMode = true means no PII is collected at all. A test asserts create is never called for anonymous.

Respondent form

  • profile-start-form.tsx (react-hook-form + Zod + FieldHelp), gated onto the start page via loadStartContext — renders only for a non-anonymous version that declares profile fields.

Read-path redaction (defence in depth)

  • Profile carried + nulled when anonymous across exports: results loader/serialize (respondent_profile column), session-export.ts, build-session-export-model.ts, and the session PDF document.

Analytics hardening

  • K_ANONYMITY_THRESHOLD = 5 cohort suppression in cost / funnel / distributions; aggregate spend always returned (no identity).
  • Cost additionally drops the per-session table whenever anonymous (session ids re-identify).
  • New suppressed / topSessionsSuppressed result fields + a { kind: 'suppressed' } distribution variant; admin panels render the suppressed states.

Tests

  • profile-snapshot.test.ts — capture invariants (anonymous never writes; invalid/empty rejected/skipped; no capture on resume).
  • profile-values.test.ts — strict validator.
  • analytics/{cost,funnel,distributions}.test.ts — suppression + anonymous guard.
  • export tests + session-export.test.ts — profile surfaced when not anonymous, dropped when anonymous.
  • app-questionnaire-schema.test.ts — model shape + both FKs ON DELETE CASCADE.

npm run validate passes (type-check + lint + format).

CHANGELOG

None — app-owned models/routes are outside the Sunrise platform surface, per the repo's platform-scoped CHANGELOG policy.

🤖 Generated with Claude Code

Make anonymousMode a real PII contract honoured at every data boundary,
and add the respondent profile-collection capability the contract gates.

- AppRespondentProfileSnapshot model (+ migration); both User and session
  FKs onDelete: Cascade so eraseUser() removes snapshots natively
- Capture seam in createSessionFromInvitation: validates + writes snapshot
  only on non-anonymous invitation path; anonymous/version-direct/no-login
  never capture (D1 — no row, not an empty row)
- Respondent profile-start form, gated onto the start page via loadStartContext
- Read-path redaction across exports (results loader/serialize, session-export,
  PDF document, build-session-export-model): profile nulled when anonymous
- Analytics hardening: k-anonymity (K=5) cohort suppression in cost/funnel/
  distributions; cost drops per-session table when anonymous; admin panels
  render suppressed states
- Privacy constants module (analytics/privacy.ts), docs (anonymous-mode.md),
  tracker (features/f8.3.md)

No CHANGELOG entry — app-owned models/routes are outside the Sunrise platform surface.
@JohnD-EE JohnD-EE merged commit 9d8ed55 into main Jun 9, 2026
14 checks passed
@JohnD-EE JohnD-EE deleted the feat/F8.3-anonymous-mode-hardening branch June 9, 2026 09:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant