Problem
The student's quiz performance record is stored in plaintext:
quiz_attempts.questions_json JSONB -- plaintext: the questions askedquiz_attempts.answers_json JSONB -- plaintext: what the student answeredquiz_context.context_json JSONB NOT NULL-- plaintext: adaptive per-concept history
Neither routes/quiz.py nor any quiz service imports services.encryption — verified by grepping every file that references the module.
Why it matters
This is educational performance data tied to a named student: what they were asked, what they got wrong, and a running model of where they are struggling. It is the same class of content as messages.content (tutor chat) and sessions.summary_json (session summary) — both of which are encrypted. sessions.summary_json is the closest precedent: it is a JSONB summary of learning activity, encrypted with encrypt_json at routes/learn.py:1036.
The questions themselves are also frequently generated from the student's own uploaded course materials, which makes this partly an instance of the derived-content pattern tracked in the sibling issue.
Proposed fix
encrypt_json at write and decrypt_json at read, exactly as sessions.summary_json already does.
Check first:
quiz_context.context_json is read on every adaptive quiz generation to build the prompt. Confirm the decrypt happens before the payload reaches the agent, and that the extra round-trip is acceptable on that path.- Confirm nothing filters or aggregates on the JSONB contents in SQL.
quiz_attempts.score / total / difficulty are separate scalar columns and should stay plaintext so analytics keep working — only the two JSON blobs are in scope. routes/admin_analytics.py rollups: verify they read the scalar columns and not the JSON.
Backfill existing rows via db/backfill_encryption.py, which already has a _encrypt_json_column helper used for documents.concept_notes and sessions.summary_json.
Acceptance
Parent: see the encryption-coverage epic. Verified against origin/main on 2026-08-02.
Problem
The student's quiz performance record is stored in plaintext:
Neither
routes/quiz.pynor any quiz service importsservices.encryption— verified by grepping every file that references the module.Why it matters
This is educational performance data tied to a named student: what they were asked, what they got wrong, and a running model of where they are struggling. It is the same class of content as
messages.content(tutor chat) andsessions.summary_json(session summary) — both of which are encrypted.sessions.summary_jsonis the closest precedent: it is a JSONB summary of learning activity, encrypted withencrypt_jsonatroutes/learn.py:1036.The questions themselves are also frequently generated from the student's own uploaded course materials, which makes this partly an instance of the derived-content pattern tracked in the sibling issue.
Proposed fix
encrypt_jsonat write anddecrypt_jsonat read, exactly assessions.summary_jsonalready does.Check first:
quiz_context.context_jsonis read on every adaptive quiz generation to build the prompt. Confirm the decrypt happens before the payload reaches the agent, and that the extra round-trip is acceptable on that path.quiz_attempts.score/total/difficultyare separate scalar columns and should stay plaintext so analytics keep working — only the two JSON blobs are in scope.routes/admin_analytics.pyrollups: verify they read the scalar columns and not the JSON.Backfill existing rows via
db/backfill_encryption.py, which already has a_encrypt_json_columnhelper used fordocuments.concept_notesandsessions.summary_json.Acceptance
quiz_attempts.questions_json,quiz_attempts.answers_json,quiz_context.context_jsonencrypted viaencrypt_jsonscore,total,difficulty,completed_at) left queryable and confirmed unaffectedCLAUDE.md+ Canopysapling-infrastructureencrypted-columns table updatedParent: see the encryption-coverage epic. Verified against
origin/mainon 2026-08-02.