Skip to content

Pulse survey anonymity โ€‹

WS9 (GAC-52) ยท gates sub-task 9.1. This is the privacy review the spec (ยง12) requires before storing pulse responses.

The guarantee โ€‹

For an anonymous survey, a stored response is not re-identifiable to the member who gave it. There is no column, foreign key, or join path from an answer back to a person.

A survey's anonymity is chosen by the admin when the survey is opened (pulse_surveys.anonymous). It cannot be changed after responses exist (closing, not editing, is the lifecycle).

How it's enforced (schema) โ€‹

Two facts are stored separately:

TableHoldsIdentity
pulse_participationthat a member submitted (survey_id, user_id)member id โ€” no answers
pulse_responseswhat was answered (survey_id, question_id, value)respondent_id
  • Anonymous survey: pulse_responses.respondent_id is NULL. Participation records only that the member submitted (for the one-per-member guard and the response rate) โ€” never their answers. The two tables share no key that ties a participation row to a response row, so "who answered what" cannot be reconstructed by any query.
  • Non-anonymous survey: respondent_id is the member, and results may be attributed.

Residual risks (accepted for v1) โ€‹

  • Timing correlation. A response and its participation row are written in the same transaction, so a privileged operator with raw DB + write-time access could correlate by timestamp. Accepted for a cooperative-internal tool; a future hardening is to batch/jitter response timestamps or commit responses separately from participation.
  • Small-N inference. Aggregates over very few respondents can be de-anonymising. The results layer should suppress breakdowns below a minimum respondent count (left as a follow-up; not enforced in this pass).

Tested invariant โ€‹

After an anonymous submission, every pulse_responses row for that survey has respondent_id IS NULL, and no query joins a response to a member. A regression that introduces such a link fails the anonymity test in apps/api/internal/router/pulse_test.go.