You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Column-level AES-256-GCM encryption (backend/services/encryption.py) covers a specific, deliberately chosen set of columns. An audit on 2026-08-02 — grepping every write boundary that imports services.encryption and cross-checking against every CREATE TABLE in backend/db/migrations/ — found several columns of the same sensitivity class sitting in plaintext.
There is currently no encryption item anywhere on the roadmap. M6 ("Stable beta with real students", 2026-09-30) lists prompt-injection hardening and an accessibility pass; encryption coverage is not mentioned. If beta means real students, this work should be sized against that date.
The dominant pattern: derived content
Several paths decrypt protected source material, generate something from it, then store the derivative in plaintext. routes/flashcards.py:17 and routes/study_guide.py:27 show it plainly — both import decrypt_if_present, decrypt_json and nothing else.
sessions.summary_jsonis encrypted (routes/learn.py:1036), so this call has already been made correctly once — these are the instances that were missed.
job_applications (12 rows in prod, fully plaintext PII) will NOT be encrypted. Decision by @AndresL230 on 2026-08-02: that data is to be deleted rather than protected. Recorded here so it does not get re-filed as a gap. The rows remain in production plaintext until that deletion happens.
Cross-cutting constraints
Three things that make these decisions rather than find-and-replace, and that every child issue should respect:
Existing production rows need a backfill.db/backfill_encryption.py already implements the idempotent walker (--apply, --table, plus a _encrypt_json_column helper for JSONB). Extend it rather than writing a new one, and run staging before prod.
Definition of done for the epic
Every child closed or explicitly deferred with recorded reasoning
CLAUDE.md's encrypted-columns list and the Canopy sapling-infrastructure table both match reality
Every newly-encrypted column has a raw-SQL round-trip test, matching backend/tests/integration/test_encryption_roundtrip.py — the epic [EPIC] Chapter 1 — E2E Regression Suite #402 success criterion already says "every encrypted column covered by a raw-SQL round-trip", so new columns inherit that bar
Any column deliberately left plaintext is documented as an intentional exception, not left silent
Audit performed against origin/main on 2026-08-02.
Why this epic exists
Column-level AES-256-GCM encryption (
backend/services/encryption.py) covers a specific, deliberately chosen set of columns. An audit on 2026-08-02 — grepping every write boundary that importsservices.encryptionand cross-checking against everyCREATE TABLEinbackend/db/migrations/— found several columns of the same sensitivity class sitting in plaintext.There is currently no encryption item anywhere on the roadmap. M6 ("Stable beta with real students", 2026-09-30) lists prompt-injection hardening and an accessibility pass; encryption coverage is not mentioned. If beta means real students, this work should be sized against that date.
The dominant pattern: derived content
Several paths decrypt protected source material, generate something from it, then store the derivative in plaintext.
routes/flashcards.py:17androutes/study_guide.py:27show it plainly — both importdecrypt_if_present, decrypt_jsonand nothing else.flashcards.front/.backdocuments.*study_guides.contentdocuments.*room_summaries.summaryroom_messages.textcourse_chunks.chunk_textdocuments.extracted_textsessions.summary_jsonis encrypted (routes/learn.py:1036), so this call has already been made correctly once — these are the instances that were missed.Children
P2newsletter_emails.emailinconsistency; needs a decision, not a swapP3feedback.comment,issue_reports.descriptionP2questions_json,answers_json,context_jsonP2course_chunks.chunk_text(pre-existing; see the embedding caveat added there)Explicitly out of scope
job_applications(12 rows in prod, fully plaintext PII) will NOT be encrypted. Decision by @AndresL230 on 2026-08-02: that data is to be deleted rather than protected. Recorded here so it does not get re-filed as a gap. The rows remain in production plaintext until that deletion happens.Cross-cutting constraints
Three things that make these decisions rather than find-and-replace, and that every child issue should respect:
UNIQUEconstraint, an index used for lookup, an upsert conflict target, or aWHERE/ORDER BYon its value cannot simply be encrypted. newsletter_emails.email is plaintext while users.email is encrypted — needs a decision, not a swap #519 is the clearest case.course_chunks.embedding VECTOR(768)is computed from the same plaintext and stays queryable; embeddings are partially invertible. Encryptingchunk_textalone leaves a weaker copy of the same information — noted on Decision needed: course_chunks.chunk_text is plaintext while documents.extracted_text is encrypted #484.db/backfill_encryption.pyalready implements the idempotent walker (--apply,--table, plus a_encrypt_json_columnhelper for JSONB). Extend it rather than writing a new one, and run staging before prod.Definition of done for the epic
CLAUDE.md's encrypted-columns list and the Canopysapling-infrastructuretable both match realitybackend/tests/integration/test_encryption_roundtrip.py— the epic [EPIC] Chapter 1 — E2E Regression Suite #402 success criterion already says "every encrypted column covered by a raw-SQL round-trip", so new columns inherit that barAudit performed against
origin/mainon 2026-08-02.