Survey privacy
The “For real, how about you?” survey asks a health question: ADHD. Here is exactly what happens when you answer. No empty promises: the code excerpts below are imported from the code running in production.
In one sentence
Your answer goes through Cloudflare, then our server, which immediately turns it into +1 in a counter. Nothing we store contains an identifier.
What leaves your browser
One single POST /api/answer request, with this JSON (for example):
request body{
"version": 2,
"consent": true,
"lang": "en",
"problem": "moderately",
"adhd": "suspected",
"tech": true,
"usage": "build",
"confide": "days_0",
"scoreBand": "mid",
"workAi": true,
"fatigue": {
"parallel": "two_three",
"fatigueNow": "high",
"fatigueChange": "higher",
"fog": "moderately"
}
}Plus an X-Turnstile-Token header: a single-use anti-bot token. No cookies. The name written on the badge is not sent, and neither is your exact test score: only its band.
What the server does with it
It checks the size, the values and your consent, verifies the token with Turnstile without sending it your IP, then adds +1 to one row. That’s all:
src/survey.tsexport async function handleAnswer(request: Request, env: SurveyEnv): Promise<Response> {
if (request.method !== 'POST') return empty(405, { Allow: 'POST' });
if (!isAllowedOrigin(request.headers.get('Origin'))) return empty(403);
if (!request.headers.get('Content-Type')?.startsWith('application/json')) return empty(415);
// 1. Corps borné à 1 Ko, puis validation stricte : clés et valeurs attendues seulement.
const body = await readBounded(request, MAX_BODY_BYTES);
if (body === null) return empty(413);
let answer: Answer | null = null;
try {
answer = parseAnswer(JSON.parse(body));
} catch {
answer = null;
}
if (!answer) return empty(400);
// 2. Anti-robots (Turnstile), puis limite globale de débit, sans IP.
if (!(await isHuman(request.headers.get('X-Turnstile-Token'), env.TURNSTILE_SECRET))) return empty(403);
const { success } = await env.ANSWER_LIMITER.limit({ key: 'answer' });
if (!success) return empty(429);
// 3. Une réponse = +1 sur une ligne du tronc et, si l'IA a servi au travail, +1 sur
// 8 paires du bloc fatigue. Ni IP, ni identifiant, ni heure : seulement le mois.
// Le batch D1 est atomique : tout ou rien.
const month = new Date().toISOString().slice(0, 7);
const statements = [
env.DB.prepare(
`INSERT INTO answers_v2 (version, month, lang, problem, adhd, tech, usage, confide, work_ai, score_band, n)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 1)
ON CONFLICT (version, month, lang, problem, adhd, tech, usage, confide, work_ai, score_band)
DO UPDATE SET n = n + 1`,
).bind(
answer.version,
month,
answer.lang,
answer.problem,
answer.adhd,
answer.tech ? 1 : 0,
answer.usage,
answer.confide,
answer.workAi ? 1 : 0,
answer.scoreBand,
),
];
if (answer.fatigue) {
const values: Record<string, string> = { ...answer.fatigue, adhd: answer.adhd, usage: answer.usage, problem: answer.problem };
for (const [a, b] of FATIGUE_PAIRS) {
statements.push(
env.DB.prepare(
`INSERT INTO fatigue_pairs (version, month, a_key, a_value, b_key, b_value, n)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1)
ON CONFLICT (version, month, a_key, a_value, b_key, b_value) DO UPDATE SET n = n + 1`,
).bind(answer.version, month, a, values[a]!, b, values[b]!),
);
}
}
try {
await env.DB.batch(statements);
} catch {
// Pas de journalisation : le message d'erreur pourrait contenir les valeurs liées.
return empty(503);
}
return empty(204, { 'Cache-Control': 'no-store' });
}What we store
These are the tables of the current questionnaire (the first version’s counters table is shorter, with no extra column). The common part is one counter per combination of answers. Fatigue answers, however, are never stored together: each submission adds +1 to eight pairs, for example “4 tasks or more × fatigue going up” or “ADHD × brain fog”.
migrations/0002_v2.sql-- Tronc commun : un compteur par combinaison de réponses.
CREATE TABLE answers_v2 (
version INTEGER NOT NULL, -- 2
month TEXT NOT NULL, -- 'YYYY-MM', calculé par le serveur
lang TEXT NOT NULL, -- 'fr' | 'en'
problem TEXT NOT NULL, -- 7 derniers jours : not_at_all | a_little | moderately | a_lot | extremely
adhd TEXT NOT NULL, -- diagnosed | suspected | no | undisclosed
tech INTEGER NOT NULL, -- 0 | 1
usage TEXT NOT NULL, -- usage le plus long : build | research | support | other | mixed
confide TEXT NOT NULL, -- jours de confidences sur 7 : days_0 | days_1_2 | days_3_7 | undisclosed
work_ai INTEGER NOT NULL, -- IA utilisée pour travailler ces 7 derniers jours : 0 | 1
score_band TEXT NOT NULL, -- score au test parodique : low | mid | high
n INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (version, month, lang, problem, adhd, tech, usage, confide, work_ai, score_band)
) WITHOUT ROWID;
-- Bloc fatigue : seulement des paires de réponses, jamais la combinaison complète.
CREATE TABLE fatigue_pairs (
version INTEGER NOT NULL, -- 2
month TEXT NOT NULL, -- 'YYYY-MM'
a_key TEXT NOT NULL, -- ex. 'parallel'
a_value TEXT NOT NULL, -- ex. 'four_plus'
b_key TEXT NOT NULL, -- ex. 'fatigueChange'
b_value TEXT NOT NULL, -- ex. 'higher'
n INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (version, month, a_key, a_value, b_key, b_value)
) WITHOUT ROWID;What we don’t do
- No IP, cookie, identifier or time of day in the database: only the month.
- Server logs are off, for requests and errors alike:
wrangler.jsonc "observability": { "enabled": false }- No analytics, no third-party script other than Turnstile, which only loads once you start answering.
The limits, honestly
- Cloudflare, our host, sees your IP to route the request and run Turnstile, as with any web page.
- A cell that counts a single answer holds one person’s answers. Without an identifier it doesn’t say who, but we don’t claim absolute anonymity.
- The database keeps 30 days of history (D1 Time Travel). By comparing two states, an administrator could see which cell changed, to the minute, without knowing who answered.
- The numbers come from voluntary answers by visitors of a parody site: not a representative sample, and no substitute for a study. The “since you started using AI, this fatigue has…” question measures a perception that depends on memory, not an effect of AI.
Legal basis and rights
- Controller: cmdAI (see the legal notice). Processor: Cloudflare (hosting, D1 database in the EU jurisdiction, Turnstile).
- Legal basis: your explicit consent (GDPR articles 6.1.a and 9.2.a), given by ticking the box. Without it, the server rejects the answer.
- Retention: counters are kept as long as the project exists. No individual data is kept.
- Your rights: once your answer has melted into the counters, we can no longer find or delete it (GDPR article 11). That is the price of having no identifier. You can simply not answer, write tocontact@cmdai.fr, or contact the French data protection authority (CNIL).
Publication
Nothing is published yet. Once we have enough answers, we’ll only publish groups of at least 30 answers, as rounded percentages.
The code
The site’s repository will be made public. Meanwhile, the excerpts on this page are imported at build time from the deployed code.