The pattern
Several code paths decrypt protected source material, generate something from it, and then store the derivative in plaintext. The protection on the source is therefore only partial — the same content survives unencrypted one table over.
routes/flashcards.py:17 and routes/study_guide.py:27 make this explicit: both import decrypt_if_present, decrypt_json and nothing else. They read encrypted documents, feed them to the model, and write the output unprotected.
| Derived column | Source (encrypted) | Written at |
|---|
flashcards.front / .back | documents.* | routes/flashcards.py:239-240, 400-401 |
study_guides.content (JSONB) | documents.* | routes/study_guide.py:201 |
room_summaries.summary | room_messages.text | services/social_cache_service.py:34 |
course_chunks.chunk_text | documents.extracted_text | services/rag_service.py:209 — tracked separately in #484 |
sessions.summary_json is encrypted (routes/learn.py:1036), which proves this call has already been made correctly once. These are the instances that were missed.
Scope of this issue
flashcards, study_guides, and room_summaries. course_chunks stays in #484 — but see the note added there about the embedding, because encrypting chunk_text alone does not close it.
Proposed fix
Encrypt at each write boundary and decrypt at each read boundary, using encrypt_if_present for text and encrypt_json for the JSONB payloads, matching notes_service.py and routes/learn.py:1036.
Check before implementing — these are the things that make it more than a find-and-replace:
- Does anything filter, sort, or join on
flashcards.front/back or study_guides.content in SQL? Encrypted columns cannot be queried by value. room_summaries.member_hash is used as a cache key — confirm the summary itself is never compared, only stored and read.- Flashcard import (
routes/flashcards.py:383, 524) takes cards from the client; encrypt on that path too, not only the generated path.
Backfill existing rows via db/backfill_encryption.py.
Acceptance
Parent: see the encryption-coverage epic. Related: #484. Verified against origin/main on 2026-08-02.
The pattern
Several code paths decrypt protected source material, generate something from it, and then store the derivative in plaintext. The protection on the source is therefore only partial — the same content survives unencrypted one table over.
routes/flashcards.py:17androutes/study_guide.py:27make this explicit: both importdecrypt_if_present, decrypt_jsonand nothing else. They read encrypted documents, feed them to the model, and write the output unprotected.flashcards.front/.backdocuments.*routes/flashcards.py:239-240, 400-401study_guides.content(JSONB)documents.*routes/study_guide.py:201room_summaries.summaryroom_messages.textservices/social_cache_service.py:34course_chunks.chunk_textdocuments.extracted_textservices/rag_service.py:209— tracked separately in #484sessions.summary_jsonis encrypted (routes/learn.py:1036), which proves this call has already been made correctly once. These are the instances that were missed.Scope of this issue
flashcards,study_guides, androom_summaries.course_chunksstays in #484 — but see the note added there about the embedding, because encryptingchunk_textalone does not close it.Proposed fix
Encrypt at each write boundary and decrypt at each read boundary, using
encrypt_if_presentfor text andencrypt_jsonfor the JSONB payloads, matchingnotes_service.pyandroutes/learn.py:1036.Check before implementing — these are the things that make it more than a find-and-replace:
flashcards.front/backorstudy_guides.contentin SQL? Encrypted columns cannot be queried by value.room_summaries.member_hashis used as a cache key — confirm the summary itself is never compared, only stored and read.routes/flashcards.py:383, 524) takes cards from the client; encrypt on that path too, not only the generated path.Backfill existing rows via
db/backfill_encryption.py.Acceptance
flashcards.front/backencrypted at write, decrypted at read, on both the generated and imported pathsstudy_guides.contentencrypted viaencrypt_jsonroom_summaries.summaryencrypted at write insocial_cache_service.pytests/integration/test_encryption_roundtrip.pyCLAUDE.md+ Canopysapling-infrastructureencrypted-columns table updatedParent: see the encryption-coverage epic. Related: #484. Verified against
origin/mainon 2026-08-02.