Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions docs/decisions/0025-encrypt-rag-chunk-text.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
# 0025: Encrypt `course_chunks.chunk_text` — restore the encryption boundary, but the embedding stays plaintext

- Status: accepted
- Date: 2026-07-31
- Relates to: #484 (this decision), #483 (blocked on it), #482 (RAG hardening),
#231 (storage/RLS lockdown), migration 0030 (`documents.extracted_text`),
migration 0039 (the vector store)
- Supersedes: none

## Context

#484 names a real asymmetry. `documents.extracted_text` is encrypted (0030)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the issue reference so Markdownlint can parse the paragraph.

Line 12 starts with #484 without a space. Markdownlint reports MD018 because it parses this as an invalid ATX heading. Replace it with Issue #484 names a real asymmetry.

Proposed fix
-#484 names a real asymmetry. `documents.extracted_text` is encrypted (0030)+Issue `#484` names a real asymmetry. `documents.extracted_text` is encrypted (0030)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#484 names a real asymmetry. `documents.extracted_text` is encrypted (0030)
Issue `#484` names a real asymmetry. `documents.extracted_text` is encrypted (0030)
🧰 Tools
🪛 markdownlint-cli2 (0.23.1)

[warning] 12-12: No space after hash on atx style heading

(MD018, no-missing-space-atx)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/decisions/0025-encrypt-rag-chunk-text.md` at line 12, Update the
paragraph beginning with “#484” to begin with “Issue `#484` names a real
asymmetry.” so Markdownlint no longer interprets it as an invalid heading, while
preserving the rest of the paragraph.

Source: Linters/SAST tools

because it is student-uploaded content; `course_chunks.chunk_text` holds *the
same text, chunked*, in plaintext. One column is treated as PII and the other
isn't, for no reason anyone wrote down.

**The issue's stated premise is wrong, and it matters.** It assumes "pgvector
similarity can't run over ciphertext." `match_course_chunks` (0039) computes
`1 - (c.embedding <=> query_embedding)`, orders by `c.embedding <=>
query_embedding`, and filters `WHERE c.embedding IS NOT NULL`. `chunk_text` is
only ever SELECTed as payload — the ranking never reads it. Nothing in the
codebase queries it by content either (no `ILIKE` / `LIKE` / FTS; verified
across `services/`, `routes/`, `scripts/`). So encryption does not block
retrieval at all, and this decision is far cheaper than the issue implies.

But the correction cuts both ways, and this is the part worth recording: **the
reason it's cheap is the reason it's partial.** The `embedding` column cannot
be encrypted — pgvector must compute distance over it — and an embedding is a
lossy but real representation of its source text; embedding-inversion
techniques recover substantial content from vectors alone. Encrypting
`chunk_text` therefore does *not* make the row opaque.

Threat model, for calibration: the backend connects with the service-role key
and RLS locks out `anon`/`authenticated` (#231), so direct table reads imply a
Supabase credential compromise or an insider. `ENCRYPTION_KEY` is a separate
secret held in the app environment, so column encryption genuinely raises the
bar against a database-only compromise — the same bar every other encrypted
column is already set at.

## Decision

Encrypt `chunk_text` for **every** row in `course_chunks` — document *and*
catalog — through the standard `encrypt_if_present` / `decrypt_if_present`
helpers, and compute content-addressed ids on the **plaintext, before
encryption**.

Uniform rather than scoped to document chunks, even though catalog chunks are
public BU course-catalog text with nothing to protect:

- One invariant — "`chunk_text` is always ciphertext" — is assertable by the
existing `ciphertext` e2e oracle. A per-category rule is not.
- `decrypt_if_present` returns the **raw value** when it cannot decrypt. In a
mixed table that makes a genuine decrypt failure indistinguishable from a
legitimately-plaintext catalog row, which is precisely the kind of silent
degradation #482 was about.
Comment on lines +52 to +55

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
rg -n -C 12 \
-e 'def (encrypt_if_present|decrypt_if_present)' \
-e '\bdecrypt_if_present\b' \
-e '\bplan_migration\b' \
-e 'backfill_encryption' \
backend ||true

Repository: SaplingLearn/Sapling

Length of output: 50379


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== relevant file sizes =="
wc -l backend/services/encryption.py backend/scripts/dedupe_course_chunks.py backend/scripts/backfill_document_chunks.py backend/db/backfill_encryption.py docs/decisions/0025-encrypt-rag-chunk-text.md
echoecho"== backend/services/encryption.py =="
cat -n backend/services/encryption.py
echoecho"== backend/scripts/dedupe_course_chunks.py =="
cat -n backend/scripts/dedupe_course_chunks.py
echoecho"== backend/scripts/backfill_document_chunks.py relevant section =="
cat -n backend/scripts/backfill_document_chunks.py | sed -n '1,180p'echoecho"== backend/db/backfill_encryption.py relevant section =="
cat -n backend/db/backfill_encryption.py | sed -n '1,220p'echoecho"== docs/decisions/0025-encrypt-rag-chunk-text.md relevant section =="
cat -n docs/decisions/0025-encrypt-rag-chunk-text.md | sed -n '1,90p'echoecho"== tests mentioning chunk dedupe or encryption migration =="
rg -n -C 4 'dedupe_course_chunks|decrypt_if_present|backfill_document_chunks|chunk_hash|chunkid|chunk_id' backend/tests backend/scripts backend/db docs ||true

Repository: SaplingLearn/Sapling

Length of output: 50377


🏁 Script executed:

#!/bin/bashset -euo pipefail
python3 - <<'PY'import base64from cryptography.hazmat.primitives.ciphers.aead import AESGCMkey = bytes.fromhex("00"*32)aesgcm = AESGCM(key)plaintext = "hello world"nonce = b"0" * 12ciphertext = aesgcm.encrypt(nonce, plaintext.encode(), None)stored = base64.b64encode(nonce + ciphertext).decode()wrong_aesgcm = AESGCM(bytes.fromhex("11"*32))try: # This ciphertext is valid-looking base64 + AES nonce length for the current helper wrong_aesgcm.decrypt(nonce, ciphertext, None) decryption_succeeds = Trueexcept Exception as exc: decryption_succeeds = False exc_type = type(exc).__name__is_encrypted_fingerprint = stored.isascii() and base64.b64decode(stored)print({ "wrong_key_decrypt_succeeds": decryption_succeeds, "wrong_key_exception_type": exc_type if not decryption_succeeds else None, "ciphertext_base64_len": len(stored), "ciphertext_base64_is_ascii": stored.isascii(),})PYechoecho"== targeted encryption-related tests and scripts =="
rg -n -C 6 'decrypt_if_present.*fallback|fallback.*raw|ENCRYPTION_KEY|ciphertext|course_chunks|backfill_encryption|dedupe_course_chunks|plan_migration|extracted_text' \
backend tests frontend docs/dev \
--glob '!backend/prompts/**' \
--glob '!backend/db/backfill_encryption.py' \
--glob '!backend/services/encryption.py' \
| head -240

Repository: SaplingLearn/Sapling

Length of output: 283


🌐 Web query:

Python cryptography library AESGCM decrypt wrong key exception

💡 Result:

In the Python cryptography library, the AESGCM.decrypt method raises a cryptography.exceptions.InvalidTag exception when decryption fails [1][2]. This exception is not specific only to an incorrect key; it is raised whenever the authentication tag cannot be validated [1][2]. Because AES-GCM is an authenticated encryption mode, the authentication tag is verified during decryption to ensure the integrity and authenticity of the ciphertext [3]. The verification will fail—triggering the InvalidTag exception—if any of the following are incorrect [1][2]: * The decryption key * The nonce (initialization vector) * The associated data (if any was provided during encryption) * The ciphertext itself (if it has been tampered with or corrupted) The library does not provide a distinct exception for an incorrect key because the nature of authenticated encryption means that from the perspective of the decryption process, any mismatch in these parameters results in an invalid authentication tag [3]. If you encounter this exception, ensure that the key, nonce, and associated data used for decryption match exactly those used during encryption [1][2].

Citations:


Make raw fallback fail closed during migration.

decrypt_if_present treats any decryption failure as legacy plaintext. A wrong key, corrupted ciphertext, or malformed AEAD header can be hashed as row["chunk_text"] by backend/scripts/dedupe_course_chunks.py, delete the real content-addressed row, and leave its metadata attached to ciphertext. Keep the fallback only where plaintext is positively identified (for example, during a migration backfill), and raise on encryption auth/key/format failures.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/decisions/0025-encrypt-rag-chunk-text.md` around lines 52 - 55, Update
decrypt_if_present so decryption authentication, key, and format failures raise
instead of returning the raw value, preventing dedupe_course_chunks from hashing
ciphertext or deleting the wrong content-addressed row. Retain raw fallback only
behind an explicit, positively identified plaintext migration/backfill path, and
preserve normal decryption behavior for valid encrypted values.

- The cost is ~5 AES-GCM decrypts per retrieval (`k=5`). Immaterial.

Ids stay keyed on plaintext because AES-GCM uses a random nonce per call: the
same plaintext encrypts to different ciphertext every time, so ciphertext can
never be a dedup key. `chunk_id(course, text)` remains the merge key, and
re-uploads of identical content still converge on one row.
Comment on lines +58 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Preserve the separate document and catalog ID namespaces.

The existing contract is not one chunk_id(course, text) rule for every row. backend/services/rag_service.py:161-177 documents course::document::text for document rows and course::text for catalog rows. Rewrite this paragraph to require plaintext hashing before encryption while preserving both namespaces. Otherwise an implementation could change existing IDs or create cross-category collisions.

Proposed clarification
-Ids stay keyed on plaintext because AES-GCM uses a random nonce per call: the-same plaintext encrypts to different ciphertext every time, so ciphertext can-never be a dedup key. `chunk_id(course, text)` remains the merge key, and-re-uploads of identical content still converge on one row.+Ids stay keyed on plaintext because AES-GCM uses a random nonce per call: the+same plaintext encrypts to different ciphertext every time, so ciphertext can+never be a dedup key. Preserve the existing per-kind namespaces: document rows+use `chunk_id(course, text)` and catalog rows use `sha256(course::text)`.+Encryption occurs after ID derivation, so identical content still converges+within each row kind.

This follows the ID contract documented in backend/services/rag_service.py:161-177.

📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Ids stay keyed on plaintext because AES-GCM uses a random nonce per call: the
same plaintext encrypts to different ciphertext every time, so ciphertext can
never be a dedup key. `chunk_id(course, text)` remains the merge key, and
re-uploads of identical content still converge on one row.
Ids stay keyed on plaintext because AES-GCM uses a random nonce per call: the
same plaintext encrypts to different ciphertext every time, so ciphertext can
never be a dedup key. Preserve the existing per-kind namespaces: document rows
use `chunk_id(course, text)` and catalog rows use `sha256(course::text)`.
Encryption occurs after ID derivation, so identical content still converges
within each row kind.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/decisions/0025-encrypt-rag-chunk-text.md` around lines 58 - 61, Rewrite
the paragraph to state that IDs are derived from plaintext before encryption and
preserve the separate namespaces: document rows use course::document::text,
while catalog rows use course::text. Ensure the clarification retains stable
existing IDs and prevents cross-category collisions.


**This ADR does not claim chunk confidentiality.** It restores boundary
consistency and removes trivially-readable plaintext. The embedding remains,
and it is the residual exposure.

## Consequences

- (+) The encryption boundary is consistent: the same student text is
protected in `documents.extracted_text` and in the chunks derived from it.
- (+) Retrieval is unaffected — ranking never touched `chunk_text`.
- (+) Unblocks #483. Notes indexing would otherwise write decrypted note
bodies (`notes.body` is encrypted) into a plaintext column, deepening the
very asymmetry this closes.
- (+) A uniform invariant the `ciphertext` oracle can assert, so a future
regression fails a lane instead of sitting unnoticed.
- (−) **Residual exposure: the embedding stays plaintext and is partially
invertible.** This is defense in depth, not confidentiality. Anyone reading
this ADR to answer "is chunk content protected?" must read this line.
- (−) `scripts/dedupe_course_chunks.py:70` re-derives ids from the **stored**
`chunk_text`. Run against encrypted rows it would hash ciphertext and
destroy content-addressing. It must decrypt before hashing, or be retired —
its own docstring calls it a one-time migration.
- (−) A backfill is required for existing rows (the
`db/backfill_encryption.py` precedent). Until it completes the table is
mixed, carried by `decrypt_if_present`'s raw-value fallback.
Comment on lines +75 to +86

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Define the ciphertext-oracle rollout order.

The ADR says the oracle should assert that every chunk_text is ciphertext. It also says legacy plaintext remains until backfill. State that the strict oracle runs only after backfill, and define the expected behavior during the mixed phase. Otherwise the rollout either fails a valid pre-backfill state or weakens the invariant.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/decisions/0025-encrypt-rag-chunk-text.md` around lines 75 - 86, Update
the ADR’s ciphertext-oracle rollout description to state that the strict “every
chunk_text is ciphertext” assertion is enabled only after encryption backfill
completes. Define the mixed-phase behavior explicitly: existing plaintext
remains supported through decrypt_if_present’s raw-value fallback while new or
migrated rows follow the encrypted format, then switch to strict validation once
backfill finishes.

- (−) Catalog chunks pay encryption cost for no privacy benefit. Accepted as
the price of the uniform invariant.

## Implementation (not done here)

Write sites: `services/rag_service.py::index_document_chunks` and
`scripts/ingest_catalog.py`. Read site:
`services/rag_service.py::retrieve_chunks`, decrypting each returned chunk
before `format_rag_context`. Plus the dedupe-script fix above, a backfill, and
extending the `ciphertext` oracle to cover the column.
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions docs/decisions/0025-encrypt-rag-chunk-text.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
# 0025: Encrypt `course_chunks.chunk_text` — restore the encryption boundary, but the embedding stays plaintext

- Status: accepted
- Date: 2026-07-31
- Relates to: #484 (this decision), #483 (blocked on it), #482 (RAG hardening),
#231 (storage/RLS lockdown), migration 0030 (`documents.extracted_text`),
migration 0039 (the vector store)
- Supersedes: none

## Context

#484 names a real asymmetry. `documents.extracted_text` is encrypted (0030)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the issue reference so Markdownlint can parse the paragraph.

Line 12 starts with #484 without a space. Markdownlint reports MD018 because it parses this as an invalid ATX heading. Replace it with Issue #484 names a real asymmetry.

Proposed fix
-#484 names a real asymmetry. `documents.extracted_text` is encrypted (0030)+Issue `#484` names a real asymmetry. `documents.extracted_text` is encrypted (0030)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#484 names a real asymmetry. `documents.extracted_text` is encrypted (0030)
Issue `#484` names a real asymmetry. `documents.extracted_text` is encrypted (0030)
🧰 Tools
🪛 markdownlint-cli2 (0.23.1)

[warning] 12-12: No space after hash on atx style heading

(MD018, no-missing-space-atx)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/decisions/0025-encrypt-rag-chunk-text.md` at line 12, Update the
paragraph beginning with “#484” to begin with “Issue `#484` names a real
asymmetry.” so Markdownlint no longer interprets it as an invalid heading, while
preserving the rest of the paragraph.

Source: Linters/SAST tools

because it is student-uploaded content; `course_chunks.chunk_text` holds *the
same text, chunked*, in plaintext. One column is treated as PII and the other
isn't, for no reason anyone wrote down.

**The issue's stated premise is wrong, and it matters.** It assumes "pgvector
similarity can't run over ciphertext." `match_course_chunks` (0039) computes
`1 - (c.embedding <=> query_embedding)`, orders by `c.embedding <=>
query_embedding`, and filters `WHERE c.embedding IS NOT NULL`. `chunk_text` is
only ever SELECTed as payload — the ranking never reads it. Nothing in the
codebase queries it by content either (no `ILIKE` / `LIKE` / FTS; verified
across `services/`, `routes/`, `scripts/`). So encryption does not block
retrieval at all, and this decision is far cheaper than the issue implies.

But the correction cuts both ways, and this is the part worth recording: **the
reason it's cheap is the reason it's partial.** The `embedding` column cannot
be encrypted — pgvector must compute distance over it — and an embedding is a
lossy but real representation of its source text; embedding-inversion
techniques recover substantial content from vectors alone. Encrypting
`chunk_text` therefore does *not* make the row opaque.

Threat model, for calibration: the backend connects with the service-role key
and RLS locks out `anon`/`authenticated` (#231), so direct table reads imply a
Supabase credential compromise or an insider. `ENCRYPTION_KEY` is a separate
secret held in the app environment, so column encryption genuinely raises the
bar against a database-only compromise — the same bar every other encrypted
column is already set at.

## Decision

Encrypt `chunk_text` for **every** row in `course_chunks` — document *and*
catalog — through the standard `encrypt_if_present` / `decrypt_if_present`
helpers, and compute content-addressed ids on the **plaintext, before
encryption**.

Uniform rather than scoped to document chunks, even though catalog chunks are
public BU course-catalog text with nothing to protect:

- One invariant — "`chunk_text` is always ciphertext" — is assertable by the
existing `ciphertext` e2e oracle. A per-category rule is not.
- `decrypt_if_present` returns the **raw value** when it cannot decrypt. In a
mixed table that makes a genuine decrypt failure indistinguishable from a
legitimately-plaintext catalog row, which is precisely the kind of silent
degradation #482 was about.
Comment on lines +52 to +55

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
rg -n -C 12 \
-e 'def (encrypt_if_present|decrypt_if_present)' \
-e '\bdecrypt_if_present\b' \
-e '\bplan_migration\b' \
-e 'backfill_encryption' \
backend ||true

Repository: SaplingLearn/Sapling

Length of output: 50379


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== relevant file sizes =="
wc -l backend/services/encryption.py backend/scripts/dedupe_course_chunks.py backend/scripts/backfill_document_chunks.py backend/db/backfill_encryption.py docs/decisions/0025-encrypt-rag-chunk-text.md
echoecho"== backend/services/encryption.py =="
cat -n backend/services/encryption.py
echoecho"== backend/scripts/dedupe_course_chunks.py =="
cat -n backend/scripts/dedupe_course_chunks.py
echoecho"== backend/scripts/backfill_document_chunks.py relevant section =="
cat -n backend/scripts/backfill_document_chunks.py | sed -n '1,180p'echoecho"== backend/db/backfill_encryption.py relevant section =="
cat -n backend/db/backfill_encryption.py | sed -n '1,220p'echoecho"== docs/decisions/0025-encrypt-rag-chunk-text.md relevant section =="
cat -n docs/decisions/0025-encrypt-rag-chunk-text.md | sed -n '1,90p'echoecho"== tests mentioning chunk dedupe or encryption migration =="
rg -n -C 4 'dedupe_course_chunks|decrypt_if_present|backfill_document_chunks|chunk_hash|chunkid|chunk_id' backend/tests backend/scripts backend/db docs ||true

Repository: SaplingLearn/Sapling

Length of output: 50377


🏁 Script executed:

#!/bin/bashset -euo pipefail
python3 - <<'PY'import base64from cryptography.hazmat.primitives.ciphers.aead import AESGCMkey = bytes.fromhex("00"*32)aesgcm = AESGCM(key)plaintext = "hello world"nonce = b"0" * 12ciphertext = aesgcm.encrypt(nonce, plaintext.encode(), None)stored = base64.b64encode(nonce + ciphertext).decode()wrong_aesgcm = AESGCM(bytes.fromhex("11"*32))try: # This ciphertext is valid-looking base64 + AES nonce length for the current helper wrong_aesgcm.decrypt(nonce, ciphertext, None) decryption_succeeds = Trueexcept Exception as exc: decryption_succeeds = False exc_type = type(exc).__name__is_encrypted_fingerprint = stored.isascii() and base64.b64decode(stored)print({ "wrong_key_decrypt_succeeds": decryption_succeeds, "wrong_key_exception_type": exc_type if not decryption_succeeds else None, "ciphertext_base64_len": len(stored), "ciphertext_base64_is_ascii": stored.isascii(),})PYechoecho"== targeted encryption-related tests and scripts =="
rg -n -C 6 'decrypt_if_present.*fallback|fallback.*raw|ENCRYPTION_KEY|ciphertext|course_chunks|backfill_encryption|dedupe_course_chunks|plan_migration|extracted_text' \
backend tests frontend docs/dev \
--glob '!backend/prompts/**' \
--glob '!backend/db/backfill_encryption.py' \
--glob '!backend/services/encryption.py' \
| head -240

Repository: SaplingLearn/Sapling

Length of output: 283


🌐 Web query:

Python cryptography library AESGCM decrypt wrong key exception

💡 Result:

In the Python cryptography library, the AESGCM.decrypt method raises a cryptography.exceptions.InvalidTag exception when decryption fails [1][2]. This exception is not specific only to an incorrect key; it is raised whenever the authentication tag cannot be validated [1][2]. Because AES-GCM is an authenticated encryption mode, the authentication tag is verified during decryption to ensure the integrity and authenticity of the ciphertext [3]. The verification will fail—triggering the InvalidTag exception—if any of the following are incorrect [1][2]: * The decryption key * The nonce (initialization vector) * The associated data (if any was provided during encryption) * The ciphertext itself (if it has been tampered with or corrupted) The library does not provide a distinct exception for an incorrect key because the nature of authenticated encryption means that from the perspective of the decryption process, any mismatch in these parameters results in an invalid authentication tag [3]. If you encounter this exception, ensure that the key, nonce, and associated data used for decryption match exactly those used during encryption [1][2].

Citations:


Make raw fallback fail closed during migration.

decrypt_if_present treats any decryption failure as legacy plaintext. A wrong key, corrupted ciphertext, or malformed AEAD header can be hashed as row["chunk_text"] by backend/scripts/dedupe_course_chunks.py, delete the real content-addressed row, and leave its metadata attached to ciphertext. Keep the fallback only where plaintext is positively identified (for example, during a migration backfill), and raise on encryption auth/key/format failures.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/decisions/0025-encrypt-rag-chunk-text.md` around lines 52 - 55, Update
decrypt_if_present so decryption authentication, key, and format failures raise
instead of returning the raw value, preventing dedupe_course_chunks from hashing
ciphertext or deleting the wrong content-addressed row. Retain raw fallback only
behind an explicit, positively identified plaintext migration/backfill path, and
preserve normal decryption behavior for valid encrypted values.

- The cost is ~5 AES-GCM decrypts per retrieval (`k=5`). Immaterial.

Ids stay keyed on plaintext because AES-GCM uses a random nonce per call: the
same plaintext encrypts to different ciphertext every time, so ciphertext can
never be a dedup key. `chunk_id(course, text)` remains the merge key, and
re-uploads of identical content still converge on one row.
Comment on lines +58 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Preserve the separate document and catalog ID namespaces.

The existing contract is not one chunk_id(course, text) rule for every row. backend/services/rag_service.py:161-177 documents course::document::text for document rows and course::text for catalog rows. Rewrite this paragraph to require plaintext hashing before encryption while preserving both namespaces. Otherwise an implementation could change existing IDs or create cross-category collisions.

Proposed clarification
-Ids stay keyed on plaintext because AES-GCM uses a random nonce per call: the-same plaintext encrypts to different ciphertext every time, so ciphertext can-never be a dedup key. `chunk_id(course, text)` remains the merge key, and-re-uploads of identical content still converge on one row.+Ids stay keyed on plaintext because AES-GCM uses a random nonce per call: the+same plaintext encrypts to different ciphertext every time, so ciphertext can+never be a dedup key. Preserve the existing per-kind namespaces: document rows+use `chunk_id(course, text)` and catalog rows use `sha256(course::text)`.+Encryption occurs after ID derivation, so identical content still converges+within each row kind.

This follows the ID contract documented in backend/services/rag_service.py:161-177.

📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Ids stay keyed on plaintext because AES-GCM uses a random nonce per call: the
same plaintext encrypts to different ciphertext every time, so ciphertext can
never be a dedup key. `chunk_id(course, text)` remains the merge key, and
re-uploads of identical content still converge on one row.
Ids stay keyed on plaintext because AES-GCM uses a random nonce per call: the
same plaintext encrypts to different ciphertext every time, so ciphertext can
never be a dedup key. Preserve the existing per-kind namespaces: document rows
use `chunk_id(course, text)` and catalog rows use `sha256(course::text)`.
Encryption occurs after ID derivation, so identical content still converges
within each row kind.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/decisions/0025-encrypt-rag-chunk-text.md` around lines 58 - 61, Rewrite
the paragraph to state that IDs are derived from plaintext before encryption and
preserve the separate namespaces: document rows use course::document::text,
while catalog rows use course::text. Ensure the clarification retains stable
existing IDs and prevents cross-category collisions.


**This ADR does not claim chunk confidentiality.** It restores boundary
consistency and removes trivially-readable plaintext. The embedding remains,
and it is the residual exposure.

## Consequences

- (+) The encryption boundary is consistent: the same student text is
protected in `documents.extracted_text` and in the chunks derived from it.
- (+) Retrieval is unaffected — ranking never touched `chunk_text`.
- (+) Unblocks #483. Notes indexing would otherwise write decrypted note
bodies (`notes.body` is encrypted) into a plaintext column, deepening the
very asymmetry this closes.
- (+) A uniform invariant the `ciphertext` oracle can assert, so a future
regression fails a lane instead of sitting unnoticed.
- (−) **Residual exposure: the embedding stays plaintext and is partially
invertible.** This is defense in depth, not confidentiality. Anyone reading
this ADR to answer "is chunk content protected?" must read this line.
- (−) `scripts/dedupe_course_chunks.py:70` re-derives ids from the **stored**
`chunk_text`. Run against encrypted rows it would hash ciphertext and
destroy content-addressing. It must decrypt before hashing, or be retired —
its own docstring calls it a one-time migration.
- (−) A backfill is required for existing rows (the
`db/backfill_encryption.py` precedent). Until it completes the table is
mixed, carried by `decrypt_if_present`'s raw-value fallback.
Comment on lines +75 to +86

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Define the ciphertext-oracle rollout order.

The ADR says the oracle should assert that every chunk_text is ciphertext. It also says legacy plaintext remains until backfill. State that the strict oracle runs only after backfill, and define the expected behavior during the mixed phase. Otherwise the rollout either fails a valid pre-backfill state or weakens the invariant.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/decisions/0025-encrypt-rag-chunk-text.md` around lines 75 - 86, Update
the ADR’s ciphertext-oracle rollout description to state that the strict “every
chunk_text is ciphertext” assertion is enabled only after encryption backfill
completes. Define the mixed-phase behavior explicitly: existing plaintext
remains supported through decrypt_if_present’s raw-value fallback while new or
migrated rows follow the encrypted format, then switch to strict validation once
backfill finishes.

- (−) Catalog chunks pay encryption cost for no privacy benefit. Accepted as
the price of the uniform invariant.

## Implementation (not done here)

Write sites: `services/rag_service.py::index_document_chunks` and
`scripts/ingest_catalog.py`. Read site:
`services/rag_service.py::retrieve_chunks`, decrypting each returned chunk
before `format_rag_context`. Plus the dedupe-script fix above, a backfill, and
extending the `ciphertext` oracle to cover the column.
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions docs/decisions/0025-encrypt-rag-chunk-text.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
# 0025: Encrypt `course_chunks.chunk_text` — restore the encryption boundary, but the embedding stays plaintext

- Status: accepted
- Date: 2026-07-31
- Relates to: #484 (this decision), #483 (blocked on it), #482 (RAG hardening),
#231 (storage/RLS lockdown), migration 0030 (`documents.extracted_text`),
migration 0039 (the vector store)
- Supersedes: none

## Context

#484 names a real asymmetry. `documents.extracted_text` is encrypted (0030)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the issue reference so Markdownlint can parse the paragraph.

Line 12 starts with #484 without a space. Markdownlint reports MD018 because it parses this as an invalid ATX heading. Replace it with Issue #484 names a real asymmetry.

Proposed fix
-#484 names a real asymmetry. `documents.extracted_text` is encrypted (0030)+Issue `#484` names a real asymmetry. `documents.extracted_text` is encrypted (0030)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#484 names a real asymmetry. `documents.extracted_text` is encrypted (0030)
Issue `#484` names a real asymmetry. `documents.extracted_text` is encrypted (0030)
🧰 Tools
🪛 markdownlint-cli2 (0.23.1)

[warning] 12-12: No space after hash on atx style heading

(MD018, no-missing-space-atx)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/decisions/0025-encrypt-rag-chunk-text.md` at line 12, Update the
paragraph beginning with “#484” to begin with “Issue `#484` names a real
asymmetry.” so Markdownlint no longer interprets it as an invalid heading, while
preserving the rest of the paragraph.

Source: Linters/SAST tools

because it is student-uploaded content; `course_chunks.chunk_text` holds *the
same text, chunked*, in plaintext. One column is treated as PII and the other
isn't, for no reason anyone wrote down.

**The issue's stated premise is wrong, and it matters.** It assumes "pgvector
similarity can't run over ciphertext." `match_course_chunks` (0039) computes
`1 - (c.embedding <=> query_embedding)`, orders by `c.embedding <=>
query_embedding`, and filters `WHERE c.embedding IS NOT NULL`. `chunk_text` is
only ever SELECTed as payload — the ranking never reads it. Nothing in the
codebase queries it by content either (no `ILIKE` / `LIKE` / FTS; verified
across `services/`, `routes/`, `scripts/`). So encryption does not block
retrieval at all, and this decision is far cheaper than the issue implies.

But the correction cuts both ways, and this is the part worth recording: **the
reason it's cheap is the reason it's partial.** The `embedding` column cannot
be encrypted — pgvector must compute distance over it — and an embedding is a
lossy but real representation of its source text; embedding-inversion
techniques recover substantial content from vectors alone. Encrypting
`chunk_text` therefore does *not* make the row opaque.

Threat model, for calibration: the backend connects with the service-role key
and RLS locks out `anon`/`authenticated` (#231), so direct table reads imply a
Supabase credential compromise or an insider. `ENCRYPTION_KEY` is a separate
secret held in the app environment, so column encryption genuinely raises the
bar against a database-only compromise — the same bar every other encrypted
column is already set at.

## Decision

Encrypt `chunk_text` for **every** row in `course_chunks` — document *and*
catalog — through the standard `encrypt_if_present` / `decrypt_if_present`
helpers, and compute content-addressed ids on the **plaintext, before
encryption**.

Uniform rather than scoped to document chunks, even though catalog chunks are
public BU course-catalog text with nothing to protect:

- One invariant — "`chunk_text` is always ciphertext" — is assertable by the
existing `ciphertext` e2e oracle. A per-category rule is not.
- `decrypt_if_present` returns the **raw value** when it cannot decrypt. In a
mixed table that makes a genuine decrypt failure indistinguishable from a
legitimately-plaintext catalog row, which is precisely the kind of silent
degradation #482 was about.
Comment on lines +52 to +55

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
rg -n -C 12 \
-e 'def (encrypt_if_present|decrypt_if_present)' \
-e '\bdecrypt_if_present\b' \
-e '\bplan_migration\b' \
-e 'backfill_encryption' \
backend ||true

Repository: SaplingLearn/Sapling

Length of output: 50379


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== relevant file sizes =="
wc -l backend/services/encryption.py backend/scripts/dedupe_course_chunks.py backend/scripts/backfill_document_chunks.py backend/db/backfill_encryption.py docs/decisions/0025-encrypt-rag-chunk-text.md
echoecho"== backend/services/encryption.py =="
cat -n backend/services/encryption.py
echoecho"== backend/scripts/dedupe_course_chunks.py =="
cat -n backend/scripts/dedupe_course_chunks.py
echoecho"== backend/scripts/backfill_document_chunks.py relevant section =="
cat -n backend/scripts/backfill_document_chunks.py | sed -n '1,180p'echoecho"== backend/db/backfill_encryption.py relevant section =="
cat -n backend/db/backfill_encryption.py | sed -n '1,220p'echoecho"== docs/decisions/0025-encrypt-rag-chunk-text.md relevant section =="
cat -n docs/decisions/0025-encrypt-rag-chunk-text.md | sed -n '1,90p'echoecho"== tests mentioning chunk dedupe or encryption migration =="
rg -n -C 4 'dedupe_course_chunks|decrypt_if_present|backfill_document_chunks|chunk_hash|chunkid|chunk_id' backend/tests backend/scripts backend/db docs ||true

Repository: SaplingLearn/Sapling

Length of output: 50377


🏁 Script executed:

#!/bin/bashset -euo pipefail
python3 - <<'PY'import base64from cryptography.hazmat.primitives.ciphers.aead import AESGCMkey = bytes.fromhex("00"*32)aesgcm = AESGCM(key)plaintext = "hello world"nonce = b"0" * 12ciphertext = aesgcm.encrypt(nonce, plaintext.encode(), None)stored = base64.b64encode(nonce + ciphertext).decode()wrong_aesgcm = AESGCM(bytes.fromhex("11"*32))try: # This ciphertext is valid-looking base64 + AES nonce length for the current helper wrong_aesgcm.decrypt(nonce, ciphertext, None) decryption_succeeds = Trueexcept Exception as exc: decryption_succeeds = False exc_type = type(exc).__name__is_encrypted_fingerprint = stored.isascii() and base64.b64decode(stored)print({ "wrong_key_decrypt_succeeds": decryption_succeeds, "wrong_key_exception_type": exc_type if not decryption_succeeds else None, "ciphertext_base64_len": len(stored), "ciphertext_base64_is_ascii": stored.isascii(),})PYechoecho"== targeted encryption-related tests and scripts =="
rg -n -C 6 'decrypt_if_present.*fallback|fallback.*raw|ENCRYPTION_KEY|ciphertext|course_chunks|backfill_encryption|dedupe_course_chunks|plan_migration|extracted_text' \
backend tests frontend docs/dev \
--glob '!backend/prompts/**' \
--glob '!backend/db/backfill_encryption.py' \
--glob '!backend/services/encryption.py' \
| head -240

Repository: SaplingLearn/Sapling

Length of output: 283


🌐 Web query:

Python cryptography library AESGCM decrypt wrong key exception

💡 Result:

In the Python cryptography library, the AESGCM.decrypt method raises a cryptography.exceptions.InvalidTag exception when decryption fails [1][2]. This exception is not specific only to an incorrect key; it is raised whenever the authentication tag cannot be validated [1][2]. Because AES-GCM is an authenticated encryption mode, the authentication tag is verified during decryption to ensure the integrity and authenticity of the ciphertext [3]. The verification will fail—triggering the InvalidTag exception—if any of the following are incorrect [1][2]: * The decryption key * The nonce (initialization vector) * The associated data (if any was provided during encryption) * The ciphertext itself (if it has been tampered with or corrupted) The library does not provide a distinct exception for an incorrect key because the nature of authenticated encryption means that from the perspective of the decryption process, any mismatch in these parameters results in an invalid authentication tag [3]. If you encounter this exception, ensure that the key, nonce, and associated data used for decryption match exactly those used during encryption [1][2].

Citations:


Make raw fallback fail closed during migration.

decrypt_if_present treats any decryption failure as legacy plaintext. A wrong key, corrupted ciphertext, or malformed AEAD header can be hashed as row["chunk_text"] by backend/scripts/dedupe_course_chunks.py, delete the real content-addressed row, and leave its metadata attached to ciphertext. Keep the fallback only where plaintext is positively identified (for example, during a migration backfill), and raise on encryption auth/key/format failures.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/decisions/0025-encrypt-rag-chunk-text.md` around lines 52 - 55, Update
decrypt_if_present so decryption authentication, key, and format failures raise
instead of returning the raw value, preventing dedupe_course_chunks from hashing
ciphertext or deleting the wrong content-addressed row. Retain raw fallback only
behind an explicit, positively identified plaintext migration/backfill path, and
preserve normal decryption behavior for valid encrypted values.

- The cost is ~5 AES-GCM decrypts per retrieval (`k=5`). Immaterial.

Ids stay keyed on plaintext because AES-GCM uses a random nonce per call: the
same plaintext encrypts to different ciphertext every time, so ciphertext can
never be a dedup key. `chunk_id(course, text)` remains the merge key, and
re-uploads of identical content still converge on one row.
Comment on lines +58 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Preserve the separate document and catalog ID namespaces.

The existing contract is not one chunk_id(course, text) rule for every row. backend/services/rag_service.py:161-177 documents course::document::text for document rows and course::text for catalog rows. Rewrite this paragraph to require plaintext hashing before encryption while preserving both namespaces. Otherwise an implementation could change existing IDs or create cross-category collisions.

Proposed clarification
-Ids stay keyed on plaintext because AES-GCM uses a random nonce per call: the-same plaintext encrypts to different ciphertext every time, so ciphertext can-never be a dedup key. `chunk_id(course, text)` remains the merge key, and-re-uploads of identical content still converge on one row.+Ids stay keyed on plaintext because AES-GCM uses a random nonce per call: the+same plaintext encrypts to different ciphertext every time, so ciphertext can+never be a dedup key. Preserve the existing per-kind namespaces: document rows+use `chunk_id(course, text)` and catalog rows use `sha256(course::text)`.+Encryption occurs after ID derivation, so identical content still converges+within each row kind.

This follows the ID contract documented in backend/services/rag_service.py:161-177.

📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Ids stay keyed on plaintext because AES-GCM uses a random nonce per call: the
same plaintext encrypts to different ciphertext every time, so ciphertext can
never be a dedup key. `chunk_id(course, text)` remains the merge key, and
re-uploads of identical content still converge on one row.
Ids stay keyed on plaintext because AES-GCM uses a random nonce per call: the
same plaintext encrypts to different ciphertext every time, so ciphertext can
never be a dedup key. Preserve the existing per-kind namespaces: document rows
use `chunk_id(course, text)` and catalog rows use `sha256(course::text)`.
Encryption occurs after ID derivation, so identical content still converges
within each row kind.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/decisions/0025-encrypt-rag-chunk-text.md` around lines 58 - 61, Rewrite
the paragraph to state that IDs are derived from plaintext before encryption and
preserve the separate namespaces: document rows use course::document::text,
while catalog rows use course::text. Ensure the clarification retains stable
existing IDs and prevents cross-category collisions.


**This ADR does not claim chunk confidentiality.** It restores boundary
consistency and removes trivially-readable plaintext. The embedding remains,
and it is the residual exposure.

## Consequences

- (+) The encryption boundary is consistent: the same student text is
protected in `documents.extracted_text` and in the chunks derived from it.
- (+) Retrieval is unaffected — ranking never touched `chunk_text`.
- (+) Unblocks #483. Notes indexing would otherwise write decrypted note
bodies (`notes.body` is encrypted) into a plaintext column, deepening the
very asymmetry this closes.
- (+) A uniform invariant the `ciphertext` oracle can assert, so a future
regression fails a lane instead of sitting unnoticed.
- (−) **Residual exposure: the embedding stays plaintext and is partially
invertible.** This is defense in depth, not confidentiality. Anyone reading
this ADR to answer "is chunk content protected?" must read this line.
- (−) `scripts/dedupe_course_chunks.py:70` re-derives ids from the **stored**
`chunk_text`. Run against encrypted rows it would hash ciphertext and
destroy content-addressing. It must decrypt before hashing, or be retired —
its own docstring calls it a one-time migration.
- (−) A backfill is required for existing rows (the
`db/backfill_encryption.py` precedent). Until it completes the table is
mixed, carried by `decrypt_if_present`'s raw-value fallback.
Comment on lines +75 to +86

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Define the ciphertext-oracle rollout order.

The ADR says the oracle should assert that every chunk_text is ciphertext. It also says legacy plaintext remains until backfill. State that the strict oracle runs only after backfill, and define the expected behavior during the mixed phase. Otherwise the rollout either fails a valid pre-backfill state or weakens the invariant.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/decisions/0025-encrypt-rag-chunk-text.md` around lines 75 - 86, Update
the ADR’s ciphertext-oracle rollout description to state that the strict “every
chunk_text is ciphertext” assertion is enabled only after encryption backfill
completes. Define the mixed-phase behavior explicitly: existing plaintext
remains supported through decrypt_if_present’s raw-value fallback while new or
migrated rows follow the encrypted format, then switch to strict validation once
backfill finishes.

- (−) Catalog chunks pay encryption cost for no privacy benefit. Accepted as
the price of the uniform invariant.

## Implementation (not done here)

Write sites: `services/rag_service.py::index_document_chunks` and
`scripts/ingest_catalog.py`. Read site:
`services/rag_service.py::retrieve_chunks`, decrypting each returned chunk
before `format_rag_context`. Plus the dedupe-script fix above, a backfill, and
extending the `ciphertext` oracle to cover the column.
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions docs/decisions/0025-encrypt-rag-chunk-text.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
# 0025: Encrypt `course_chunks.chunk_text` — restore the encryption boundary, but the embedding stays plaintext

- Status: accepted
- Date: 2026-07-31
- Relates to: #484 (this decision), #483 (blocked on it), #482 (RAG hardening),
#231 (storage/RLS lockdown), migration 0030 (`documents.extracted_text`),
migration 0039 (the vector store)
- Supersedes: none

## Context

#484 names a real asymmetry. `documents.extracted_text` is encrypted (0030)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the issue reference so Markdownlint can parse the paragraph.

Line 12 starts with #484 without a space. Markdownlint reports MD018 because it parses this as an invalid ATX heading. Replace it with Issue #484 names a real asymmetry.

Proposed fix
-#484 names a real asymmetry. `documents.extracted_text` is encrypted (0030)+Issue `#484` names a real asymmetry. `documents.extracted_text` is encrypted (0030)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#484 names a real asymmetry. `documents.extracted_text` is encrypted (0030)
Issue `#484` names a real asymmetry. `documents.extracted_text` is encrypted (0030)
🧰 Tools
🪛 markdownlint-cli2 (0.23.1)

[warning] 12-12: No space after hash on atx style heading

(MD018, no-missing-space-atx)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/decisions/0025-encrypt-rag-chunk-text.md` at line 12, Update the
paragraph beginning with “#484” to begin with “Issue `#484` names a real
asymmetry.” so Markdownlint no longer interprets it as an invalid heading, while
preserving the rest of the paragraph.

Source: Linters/SAST tools

because it is student-uploaded content; `course_chunks.chunk_text` holds *the
same text, chunked*, in plaintext. One column is treated as PII and the other
isn't, for no reason anyone wrote down.

**The issue's stated premise is wrong, and it matters.** It assumes "pgvector
similarity can't run over ciphertext." `match_course_chunks` (0039) computes
`1 - (c.embedding <=> query_embedding)`, orders by `c.embedding <=>
query_embedding`, and filters `WHERE c.embedding IS NOT NULL`. `chunk_text` is
only ever SELECTed as payload — the ranking never reads it. Nothing in the
codebase queries it by content either (no `ILIKE` / `LIKE` / FTS; verified
across `services/`, `routes/`, `scripts/`). So encryption does not block
retrieval at all, and this decision is far cheaper than the issue implies.

But the correction cuts both ways, and this is the part worth recording: **the
reason it's cheap is the reason it's partial.** The `embedding` column cannot
be encrypted — pgvector must compute distance over it — and an embedding is a
lossy but real representation of its source text; embedding-inversion
techniques recover substantial content from vectors alone. Encrypting
`chunk_text` therefore does *not* make the row opaque.

Threat model, for calibration: the backend connects with the service-role key
and RLS locks out `anon`/`authenticated` (#231), so direct table reads imply a
Supabase credential compromise or an insider. `ENCRYPTION_KEY` is a separate
secret held in the app environment, so column encryption genuinely raises the
bar against a database-only compromise — the same bar every other encrypted
column is already set at.

## Decision

Encrypt `chunk_text` for **every** row in `course_chunks` — document *and*
catalog — through the standard `encrypt_if_present` / `decrypt_if_present`
helpers, and compute content-addressed ids on the **plaintext, before
encryption**.

Uniform rather than scoped to document chunks, even though catalog chunks are
public BU course-catalog text with nothing to protect:

- One invariant — "`chunk_text` is always ciphertext" — is assertable by the
existing `ciphertext` e2e oracle. A per-category rule is not.
- `decrypt_if_present` returns the **raw value** when it cannot decrypt. In a
mixed table that makes a genuine decrypt failure indistinguishable from a
legitimately-plaintext catalog row, which is precisely the kind of silent
degradation #482 was about.
Comment on lines +52 to +55

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
rg -n -C 12 \
-e 'def (encrypt_if_present|decrypt_if_present)' \
-e '\bdecrypt_if_present\b' \
-e '\bplan_migration\b' \
-e 'backfill_encryption' \
backend ||true

Repository: SaplingLearn/Sapling

Length of output: 50379


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== relevant file sizes =="
wc -l backend/services/encryption.py backend/scripts/dedupe_course_chunks.py backend/scripts/backfill_document_chunks.py backend/db/backfill_encryption.py docs/decisions/0025-encrypt-rag-chunk-text.md
echoecho"== backend/services/encryption.py =="
cat -n backend/services/encryption.py
echoecho"== backend/scripts/dedupe_course_chunks.py =="
cat -n backend/scripts/dedupe_course_chunks.py
echoecho"== backend/scripts/backfill_document_chunks.py relevant section =="
cat -n backend/scripts/backfill_document_chunks.py | sed -n '1,180p'echoecho"== backend/db/backfill_encryption.py relevant section =="
cat -n backend/db/backfill_encryption.py | sed -n '1,220p'echoecho"== docs/decisions/0025-encrypt-rag-chunk-text.md relevant section =="
cat -n docs/decisions/0025-encrypt-rag-chunk-text.md | sed -n '1,90p'echoecho"== tests mentioning chunk dedupe or encryption migration =="
rg -n -C 4 'dedupe_course_chunks|decrypt_if_present|backfill_document_chunks|chunk_hash|chunkid|chunk_id' backend/tests backend/scripts backend/db docs ||true

Repository: SaplingLearn/Sapling

Length of output: 50377


🏁 Script executed:

#!/bin/bashset -euo pipefail
python3 - <<'PY'import base64from cryptography.hazmat.primitives.ciphers.aead import AESGCMkey = bytes.fromhex("00"*32)aesgcm = AESGCM(key)plaintext = "hello world"nonce = b"0" * 12ciphertext = aesgcm.encrypt(nonce, plaintext.encode(), None)stored = base64.b64encode(nonce + ciphertext).decode()wrong_aesgcm = AESGCM(bytes.fromhex("11"*32))try: # This ciphertext is valid-looking base64 + AES nonce length for the current helper wrong_aesgcm.decrypt(nonce, ciphertext, None) decryption_succeeds = Trueexcept Exception as exc: decryption_succeeds = False exc_type = type(exc).__name__is_encrypted_fingerprint = stored.isascii() and base64.b64decode(stored)print({ "wrong_key_decrypt_succeeds": decryption_succeeds, "wrong_key_exception_type": exc_type if not decryption_succeeds else None, "ciphertext_base64_len": len(stored), "ciphertext_base64_is_ascii": stored.isascii(),})PYechoecho"== targeted encryption-related tests and scripts =="
rg -n -C 6 'decrypt_if_present.*fallback|fallback.*raw|ENCRYPTION_KEY|ciphertext|course_chunks|backfill_encryption|dedupe_course_chunks|plan_migration|extracted_text' \
backend tests frontend docs/dev \
--glob '!backend/prompts/**' \
--glob '!backend/db/backfill_encryption.py' \
--glob '!backend/services/encryption.py' \
| head -240

Repository: SaplingLearn/Sapling

Length of output: 283


🌐 Web query:

Python cryptography library AESGCM decrypt wrong key exception

💡 Result:

In the Python cryptography library, the AESGCM.decrypt method raises a cryptography.exceptions.InvalidTag exception when decryption fails [1][2]. This exception is not specific only to an incorrect key; it is raised whenever the authentication tag cannot be validated [1][2]. Because AES-GCM is an authenticated encryption mode, the authentication tag is verified during decryption to ensure the integrity and authenticity of the ciphertext [3]. The verification will fail—triggering the InvalidTag exception—if any of the following are incorrect [1][2]: * The decryption key * The nonce (initialization vector) * The associated data (if any was provided during encryption) * The ciphertext itself (if it has been tampered with or corrupted) The library does not provide a distinct exception for an incorrect key because the nature of authenticated encryption means that from the perspective of the decryption process, any mismatch in these parameters results in an invalid authentication tag [3]. If you encounter this exception, ensure that the key, nonce, and associated data used for decryption match exactly those used during encryption [1][2].

Citations:


Make raw fallback fail closed during migration.

decrypt_if_present treats any decryption failure as legacy plaintext. A wrong key, corrupted ciphertext, or malformed AEAD header can be hashed as row["chunk_text"] by backend/scripts/dedupe_course_chunks.py, delete the real content-addressed row, and leave its metadata attached to ciphertext. Keep the fallback only where plaintext is positively identified (for example, during a migration backfill), and raise on encryption auth/key/format failures.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/decisions/0025-encrypt-rag-chunk-text.md` around lines 52 - 55, Update
decrypt_if_present so decryption authentication, key, and format failures raise
instead of returning the raw value, preventing dedupe_course_chunks from hashing
ciphertext or deleting the wrong content-addressed row. Retain raw fallback only
behind an explicit, positively identified plaintext migration/backfill path, and
preserve normal decryption behavior for valid encrypted values.

- The cost is ~5 AES-GCM decrypts per retrieval (`k=5`). Immaterial.

Ids stay keyed on plaintext because AES-GCM uses a random nonce per call: the
same plaintext encrypts to different ciphertext every time, so ciphertext can
never be a dedup key. `chunk_id(course, text)` remains the merge key, and
re-uploads of identical content still converge on one row.
Comment on lines +58 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Preserve the separate document and catalog ID namespaces.

The existing contract is not one chunk_id(course, text) rule for every row. backend/services/rag_service.py:161-177 documents course::document::text for document rows and course::text for catalog rows. Rewrite this paragraph to require plaintext hashing before encryption while preserving both namespaces. Otherwise an implementation could change existing IDs or create cross-category collisions.

Proposed clarification
-Ids stay keyed on plaintext because AES-GCM uses a random nonce per call: the-same plaintext encrypts to different ciphertext every time, so ciphertext can-never be a dedup key. `chunk_id(course, text)` remains the merge key, and-re-uploads of identical content still converge on one row.+Ids stay keyed on plaintext because AES-GCM uses a random nonce per call: the+same plaintext encrypts to different ciphertext every time, so ciphertext can+never be a dedup key. Preserve the existing per-kind namespaces: document rows+use `chunk_id(course, text)` and catalog rows use `sha256(course::text)`.+Encryption occurs after ID derivation, so identical content still converges+within each row kind.

This follows the ID contract documented in backend/services/rag_service.py:161-177.

📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Ids stay keyed on plaintext because AES-GCM uses a random nonce per call: the
same plaintext encrypts to different ciphertext every time, so ciphertext can
never be a dedup key. `chunk_id(course, text)` remains the merge key, and
re-uploads of identical content still converge on one row.
Ids stay keyed on plaintext because AES-GCM uses a random nonce per call: the
same plaintext encrypts to different ciphertext every time, so ciphertext can
never be a dedup key. Preserve the existing per-kind namespaces: document rows
use `chunk_id(course, text)` and catalog rows use `sha256(course::text)`.
Encryption occurs after ID derivation, so identical content still converges
within each row kind.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/decisions/0025-encrypt-rag-chunk-text.md` around lines 58 - 61, Rewrite
the paragraph to state that IDs are derived from plaintext before encryption and
preserve the separate namespaces: document rows use course::document::text,
while catalog rows use course::text. Ensure the clarification retains stable
existing IDs and prevents cross-category collisions.


**This ADR does not claim chunk confidentiality.** It restores boundary
consistency and removes trivially-readable plaintext. The embedding remains,
and it is the residual exposure.

## Consequences

- (+) The encryption boundary is consistent: the same student text is
protected in `documents.extracted_text` and in the chunks derived from it.
- (+) Retrieval is unaffected — ranking never touched `chunk_text`.
- (+) Unblocks #483. Notes indexing would otherwise write decrypted note
bodies (`notes.body` is encrypted) into a plaintext column, deepening the
very asymmetry this closes.
- (+) A uniform invariant the `ciphertext` oracle can assert, so a future
regression fails a lane instead of sitting unnoticed.
- (−) **Residual exposure: the embedding stays plaintext and is partially
invertible.** This is defense in depth, not confidentiality. Anyone reading
this ADR to answer "is chunk content protected?" must read this line.
- (−) `scripts/dedupe_course_chunks.py:70` re-derives ids from the **stored**
`chunk_text`. Run against encrypted rows it would hash ciphertext and
destroy content-addressing. It must decrypt before hashing, or be retired —
its own docstring calls it a one-time migration.
- (−) A backfill is required for existing rows (the
`db/backfill_encryption.py` precedent). Until it completes the table is
mixed, carried by `decrypt_if_present`'s raw-value fallback.
Comment on lines +75 to +86

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Define the ciphertext-oracle rollout order.

The ADR says the oracle should assert that every chunk_text is ciphertext. It also says legacy plaintext remains until backfill. State that the strict oracle runs only after backfill, and define the expected behavior during the mixed phase. Otherwise the rollout either fails a valid pre-backfill state or weakens the invariant.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/decisions/0025-encrypt-rag-chunk-text.md` around lines 75 - 86, Update
the ADR’s ciphertext-oracle rollout description to state that the strict “every
chunk_text is ciphertext” assertion is enabled only after encryption backfill
completes. Define the mixed-phase behavior explicitly: existing plaintext
remains supported through decrypt_if_present’s raw-value fallback while new or
migrated rows follow the encrypted format, then switch to strict validation once
backfill finishes.

- (−) Catalog chunks pay encryption cost for no privacy benefit. Accepted as
the price of the uniform invariant.

## Implementation (not done here)

Write sites: `services/rag_service.py::index_document_chunks` and
`scripts/ingest_catalog.py`. Read site:
`services/rag_service.py::retrieve_chunks`, decrypting each returned chunk
before `format_rag_context`. Plus the dedupe-script fix above, a backfill, and
extending the `ciphertext` oracle to cover the column.
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions docs/decisions/0025-encrypt-rag-chunk-text.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
# 0025: Encrypt `course_chunks.chunk_text` — restore the encryption boundary, but the embedding stays plaintext

- Status: accepted
- Date: 2026-07-31
- Relates to: #484 (this decision), #483 (blocked on it), #482 (RAG hardening),
#231 (storage/RLS lockdown), migration 0030 (`documents.extracted_text`),
migration 0039 (the vector store)
- Supersedes: none

## Context

#484 names a real asymmetry. `documents.extracted_text` is encrypted (0030)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the issue reference so Markdownlint can parse the paragraph.

Line 12 starts with #484 without a space. Markdownlint reports MD018 because it parses this as an invalid ATX heading. Replace it with Issue #484 names a real asymmetry.

Proposed fix
-#484 names a real asymmetry. `documents.extracted_text` is encrypted (0030)+Issue `#484` names a real asymmetry. `documents.extracted_text` is encrypted (0030)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#484 names a real asymmetry. `documents.extracted_text` is encrypted (0030)
Issue `#484` names a real asymmetry. `documents.extracted_text` is encrypted (0030)
🧰 Tools
🪛 markdownlint-cli2 (0.23.1)

[warning] 12-12: No space after hash on atx style heading

(MD018, no-missing-space-atx)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/decisions/0025-encrypt-rag-chunk-text.md` at line 12, Update the
paragraph beginning with “#484” to begin with “Issue `#484` names a real
asymmetry.” so Markdownlint no longer interprets it as an invalid heading, while
preserving the rest of the paragraph.

Source: Linters/SAST tools

because it is student-uploaded content; `course_chunks.chunk_text` holds *the
same text, chunked*, in plaintext. One column is treated as PII and the other
isn't, for no reason anyone wrote down.

**The issue's stated premise is wrong, and it matters.** It assumes "pgvector
similarity can't run over ciphertext." `match_course_chunks` (0039) computes
`1 - (c.embedding <=> query_embedding)`, orders by `c.embedding <=>
query_embedding`, and filters `WHERE c.embedding IS NOT NULL`. `chunk_text` is
only ever SELECTed as payload — the ranking never reads it. Nothing in the
codebase queries it by content either (no `ILIKE` / `LIKE` / FTS; verified
across `services/`, `routes/`, `scripts/`). So encryption does not block
retrieval at all, and this decision is far cheaper than the issue implies.

But the correction cuts both ways, and this is the part worth recording: **the
reason it's cheap is the reason it's partial.** The `embedding` column cannot
be encrypted — pgvector must compute distance over it — and an embedding is a
lossy but real representation of its source text; embedding-inversion
techniques recover substantial content from vectors alone. Encrypting
`chunk_text` therefore does *not* make the row opaque.

Threat model, for calibration: the backend connects with the service-role key
and RLS locks out `anon`/`authenticated` (#231), so direct table reads imply a
Supabase credential compromise or an insider. `ENCRYPTION_KEY` is a separate
secret held in the app environment, so column encryption genuinely raises the
bar against a database-only compromise — the same bar every other encrypted
column is already set at.

## Decision

Encrypt `chunk_text` for **every** row in `course_chunks` — document *and*
catalog — through the standard `encrypt_if_present` / `decrypt_if_present`
helpers, and compute content-addressed ids on the **plaintext, before
encryption**.

Uniform rather than scoped to document chunks, even though catalog chunks are
public BU course-catalog text with nothing to protect:

- One invariant — "`chunk_text` is always ciphertext" — is assertable by the
existing `ciphertext` e2e oracle. A per-category rule is not.
- `decrypt_if_present` returns the **raw value** when it cannot decrypt. In a
mixed table that makes a genuine decrypt failure indistinguishable from a
legitimately-plaintext catalog row, which is precisely the kind of silent
degradation #482 was about.
Comment on lines +52 to +55

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
rg -n -C 12 \
-e 'def (encrypt_if_present|decrypt_if_present)' \
-e '\bdecrypt_if_present\b' \
-e '\bplan_migration\b' \
-e 'backfill_encryption' \
backend ||true

Repository: SaplingLearn/Sapling

Length of output: 50379


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== relevant file sizes =="
wc -l backend/services/encryption.py backend/scripts/dedupe_course_chunks.py backend/scripts/backfill_document_chunks.py backend/db/backfill_encryption.py docs/decisions/0025-encrypt-rag-chunk-text.md
echoecho"== backend/services/encryption.py =="
cat -n backend/services/encryption.py
echoecho"== backend/scripts/dedupe_course_chunks.py =="
cat -n backend/scripts/dedupe_course_chunks.py
echoecho"== backend/scripts/backfill_document_chunks.py relevant section =="
cat -n backend/scripts/backfill_document_chunks.py | sed -n '1,180p'echoecho"== backend/db/backfill_encryption.py relevant section =="
cat -n backend/db/backfill_encryption.py | sed -n '1,220p'echoecho"== docs/decisions/0025-encrypt-rag-chunk-text.md relevant section =="
cat -n docs/decisions/0025-encrypt-rag-chunk-text.md | sed -n '1,90p'echoecho"== tests mentioning chunk dedupe or encryption migration =="
rg -n -C 4 'dedupe_course_chunks|decrypt_if_present|backfill_document_chunks|chunk_hash|chunkid|chunk_id' backend/tests backend/scripts backend/db docs ||true

Repository: SaplingLearn/Sapling

Length of output: 50377


🏁 Script executed:

#!/bin/bashset -euo pipefail
python3 - <<'PY'import base64from cryptography.hazmat.primitives.ciphers.aead import AESGCMkey = bytes.fromhex("00"*32)aesgcm = AESGCM(key)plaintext = "hello world"nonce = b"0" * 12ciphertext = aesgcm.encrypt(nonce, plaintext.encode(), None)stored = base64.b64encode(nonce + ciphertext).decode()wrong_aesgcm = AESGCM(bytes.fromhex("11"*32))try: # This ciphertext is valid-looking base64 + AES nonce length for the current helper wrong_aesgcm.decrypt(nonce, ciphertext, None) decryption_succeeds = Trueexcept Exception as exc: decryption_succeeds = False exc_type = type(exc).__name__is_encrypted_fingerprint = stored.isascii() and base64.b64decode(stored)print({ "wrong_key_decrypt_succeeds": decryption_succeeds, "wrong_key_exception_type": exc_type if not decryption_succeeds else None, "ciphertext_base64_len": len(stored), "ciphertext_base64_is_ascii": stored.isascii(),})PYechoecho"== targeted encryption-related tests and scripts =="
rg -n -C 6 'decrypt_if_present.*fallback|fallback.*raw|ENCRYPTION_KEY|ciphertext|course_chunks|backfill_encryption|dedupe_course_chunks|plan_migration|extracted_text' \
backend tests frontend docs/dev \
--glob '!backend/prompts/**' \
--glob '!backend/db/backfill_encryption.py' \
--glob '!backend/services/encryption.py' \
| head -240

Repository: SaplingLearn/Sapling

Length of output: 283


🌐 Web query:

Python cryptography library AESGCM decrypt wrong key exception

💡 Result:

In the Python cryptography library, the AESGCM.decrypt method raises a cryptography.exceptions.InvalidTag exception when decryption fails [1][2]. This exception is not specific only to an incorrect key; it is raised whenever the authentication tag cannot be validated [1][2]. Because AES-GCM is an authenticated encryption mode, the authentication tag is verified during decryption to ensure the integrity and authenticity of the ciphertext [3]. The verification will fail—triggering the InvalidTag exception—if any of the following are incorrect [1][2]: * The decryption key * The nonce (initialization vector) * The associated data (if any was provided during encryption) * The ciphertext itself (if it has been tampered with or corrupted) The library does not provide a distinct exception for an incorrect key because the nature of authenticated encryption means that from the perspective of the decryption process, any mismatch in these parameters results in an invalid authentication tag [3]. If you encounter this exception, ensure that the key, nonce, and associated data used for decryption match exactly those used during encryption [1][2].

Citations:


Make raw fallback fail closed during migration.

decrypt_if_present treats any decryption failure as legacy plaintext. A wrong key, corrupted ciphertext, or malformed AEAD header can be hashed as row["chunk_text"] by backend/scripts/dedupe_course_chunks.py, delete the real content-addressed row, and leave its metadata attached to ciphertext. Keep the fallback only where plaintext is positively identified (for example, during a migration backfill), and raise on encryption auth/key/format failures.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/decisions/0025-encrypt-rag-chunk-text.md` around lines 52 - 55, Update
decrypt_if_present so decryption authentication, key, and format failures raise
instead of returning the raw value, preventing dedupe_course_chunks from hashing
ciphertext or deleting the wrong content-addressed row. Retain raw fallback only
behind an explicit, positively identified plaintext migration/backfill path, and
preserve normal decryption behavior for valid encrypted values.

- The cost is ~5 AES-GCM decrypts per retrieval (`k=5`). Immaterial.

Ids stay keyed on plaintext because AES-GCM uses a random nonce per call: the
same plaintext encrypts to different ciphertext every time, so ciphertext can
never be a dedup key. `chunk_id(course, text)` remains the merge key, and
re-uploads of identical content still converge on one row.
Comment on lines +58 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Preserve the separate document and catalog ID namespaces.

The existing contract is not one chunk_id(course, text) rule for every row. backend/services/rag_service.py:161-177 documents course::document::text for document rows and course::text for catalog rows. Rewrite this paragraph to require plaintext hashing before encryption while preserving both namespaces. Otherwise an implementation could change existing IDs or create cross-category collisions.

Proposed clarification
-Ids stay keyed on plaintext because AES-GCM uses a random nonce per call: the-same plaintext encrypts to different ciphertext every time, so ciphertext can-never be a dedup key. `chunk_id(course, text)` remains the merge key, and-re-uploads of identical content still converge on one row.+Ids stay keyed on plaintext because AES-GCM uses a random nonce per call: the+same plaintext encrypts to different ciphertext every time, so ciphertext can+never be a dedup key. Preserve the existing per-kind namespaces: document rows+use `chunk_id(course, text)` and catalog rows use `sha256(course::text)`.+Encryption occurs after ID derivation, so identical content still converges+within each row kind.

This follows the ID contract documented in backend/services/rag_service.py:161-177.

📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Ids stay keyed on plaintext because AES-GCM uses a random nonce per call: the
same plaintext encrypts to different ciphertext every time, so ciphertext can
never be a dedup key. `chunk_id(course, text)` remains the merge key, and
re-uploads of identical content still converge on one row.
Ids stay keyed on plaintext because AES-GCM uses a random nonce per call: the
same plaintext encrypts to different ciphertext every time, so ciphertext can
never be a dedup key. Preserve the existing per-kind namespaces: document rows
use `chunk_id(course, text)` and catalog rows use `sha256(course::text)`.
Encryption occurs after ID derivation, so identical content still converges
within each row kind.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/decisions/0025-encrypt-rag-chunk-text.md` around lines 58 - 61, Rewrite
the paragraph to state that IDs are derived from plaintext before encryption and
preserve the separate namespaces: document rows use course::document::text,
while catalog rows use course::text. Ensure the clarification retains stable
existing IDs and prevents cross-category collisions.


**This ADR does not claim chunk confidentiality.** It restores boundary
consistency and removes trivially-readable plaintext. The embedding remains,
and it is the residual exposure.

## Consequences

- (+) The encryption boundary is consistent: the same student text is
protected in `documents.extracted_text` and in the chunks derived from it.
- (+) Retrieval is unaffected — ranking never touched `chunk_text`.
- (+) Unblocks #483. Notes indexing would otherwise write decrypted note
bodies (`notes.body` is encrypted) into a plaintext column, deepening the
very asymmetry this closes.
- (+) A uniform invariant the `ciphertext` oracle can assert, so a future
regression fails a lane instead of sitting unnoticed.
- (−) **Residual exposure: the embedding stays plaintext and is partially
invertible.** This is defense in depth, not confidentiality. Anyone reading
this ADR to answer "is chunk content protected?" must read this line.
- (−) `scripts/dedupe_course_chunks.py:70` re-derives ids from the **stored**
`chunk_text`. Run against encrypted rows it would hash ciphertext and
destroy content-addressing. It must decrypt before hashing, or be retired —
its own docstring calls it a one-time migration.
- (−) A backfill is required for existing rows (the
`db/backfill_encryption.py` precedent). Until it completes the table is
mixed, carried by `decrypt_if_present`'s raw-value fallback.
Comment on lines +75 to +86

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Define the ciphertext-oracle rollout order.

The ADR says the oracle should assert that every chunk_text is ciphertext. It also says legacy plaintext remains until backfill. State that the strict oracle runs only after backfill, and define the expected behavior during the mixed phase. Otherwise the rollout either fails a valid pre-backfill state or weakens the invariant.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/decisions/0025-encrypt-rag-chunk-text.md` around lines 75 - 86, Update
the ADR’s ciphertext-oracle rollout description to state that the strict “every
chunk_text is ciphertext” assertion is enabled only after encryption backfill
completes. Define the mixed-phase behavior explicitly: existing plaintext
remains supported through decrypt_if_present’s raw-value fallback while new or
migrated rows follow the encrypted format, then switch to strict validation once
backfill finishes.

- (−) Catalog chunks pay encryption cost for no privacy benefit. Accepted as
the price of the uniform invariant.

## Implementation (not done here)

Write sites: `services/rag_service.py::index_document_chunks` and
`scripts/ingest_catalog.py`. Read site:
`services/rag_service.py::retrieve_chunks`, decrypting each returned chunk
before `format_rag_context`. Plus the dedupe-script fix above, a backfill, and
extending the `ciphertext` oracle to cover the column.
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions docs/decisions/0025-encrypt-rag-chunk-text.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
# 0025: Encrypt `course_chunks.chunk_text` — restore the encryption boundary, but the embedding stays plaintext

- Status: accepted
- Date: 2026-07-31
- Relates to: #484 (this decision), #483 (blocked on it), #482 (RAG hardening),
#231 (storage/RLS lockdown), migration 0030 (`documents.extracted_text`),
migration 0039 (the vector store)
- Supersedes: none

## Context

#484 names a real asymmetry. `documents.extracted_text` is encrypted (0030)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the issue reference so Markdownlint can parse the paragraph.

Line 12 starts with #484 without a space. Markdownlint reports MD018 because it parses this as an invalid ATX heading. Replace it with Issue #484 names a real asymmetry.

Proposed fix
-#484 names a real asymmetry. `documents.extracted_text` is encrypted (0030)+Issue `#484` names a real asymmetry. `documents.extracted_text` is encrypted (0030)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#484 names a real asymmetry. `documents.extracted_text` is encrypted (0030)
Issue `#484` names a real asymmetry. `documents.extracted_text` is encrypted (0030)
🧰 Tools
🪛 markdownlint-cli2 (0.23.1)

[warning] 12-12: No space after hash on atx style heading

(MD018, no-missing-space-atx)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/decisions/0025-encrypt-rag-chunk-text.md` at line 12, Update the
paragraph beginning with “#484” to begin with “Issue `#484` names a real
asymmetry.” so Markdownlint no longer interprets it as an invalid heading, while
preserving the rest of the paragraph.

Source: Linters/SAST tools

because it is student-uploaded content; `course_chunks.chunk_text` holds *the
same text, chunked*, in plaintext. One column is treated as PII and the other
isn't, for no reason anyone wrote down.

**The issue's stated premise is wrong, and it matters.** It assumes "pgvector
similarity can't run over ciphertext." `match_course_chunks` (0039) computes
`1 - (c.embedding <=> query_embedding)`, orders by `c.embedding <=>
query_embedding`, and filters `WHERE c.embedding IS NOT NULL`. `chunk_text` is
only ever SELECTed as payload — the ranking never reads it. Nothing in the
codebase queries it by content either (no `ILIKE` / `LIKE` / FTS; verified
across `services/`, `routes/`, `scripts/`). So encryption does not block
retrieval at all, and this decision is far cheaper than the issue implies.

But the correction cuts both ways, and this is the part worth recording: **the
reason it's cheap is the reason it's partial.** The `embedding` column cannot
be encrypted — pgvector must compute distance over it — and an embedding is a
lossy but real representation of its source text; embedding-inversion
techniques recover substantial content from vectors alone. Encrypting
`chunk_text` therefore does *not* make the row opaque.

Threat model, for calibration: the backend connects with the service-role key
and RLS locks out `anon`/`authenticated` (#231), so direct table reads imply a
Supabase credential compromise or an insider. `ENCRYPTION_KEY` is a separate
secret held in the app environment, so column encryption genuinely raises the
bar against a database-only compromise — the same bar every other encrypted
column is already set at.

## Decision

Encrypt `chunk_text` for **every** row in `course_chunks` — document *and*
catalog — through the standard `encrypt_if_present` / `decrypt_if_present`
helpers, and compute content-addressed ids on the **plaintext, before
encryption**.

Uniform rather than scoped to document chunks, even though catalog chunks are
public BU course-catalog text with nothing to protect:

- One invariant — "`chunk_text` is always ciphertext" — is assertable by the
existing `ciphertext` e2e oracle. A per-category rule is not.
- `decrypt_if_present` returns the **raw value** when it cannot decrypt. In a
mixed table that makes a genuine decrypt failure indistinguishable from a
legitimately-plaintext catalog row, which is precisely the kind of silent
degradation #482 was about.
Comment on lines +52 to +55

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
rg -n -C 12 \
-e 'def (encrypt_if_present|decrypt_if_present)' \
-e '\bdecrypt_if_present\b' \
-e '\bplan_migration\b' \
-e 'backfill_encryption' \
backend ||true

Repository: SaplingLearn/Sapling

Length of output: 50379


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== relevant file sizes =="
wc -l backend/services/encryption.py backend/scripts/dedupe_course_chunks.py backend/scripts/backfill_document_chunks.py backend/db/backfill_encryption.py docs/decisions/0025-encrypt-rag-chunk-text.md
echoecho"== backend/services/encryption.py =="
cat -n backend/services/encryption.py
echoecho"== backend/scripts/dedupe_course_chunks.py =="
cat -n backend/scripts/dedupe_course_chunks.py
echoecho"== backend/scripts/backfill_document_chunks.py relevant section =="
cat -n backend/scripts/backfill_document_chunks.py | sed -n '1,180p'echoecho"== backend/db/backfill_encryption.py relevant section =="
cat -n backend/db/backfill_encryption.py | sed -n '1,220p'echoecho"== docs/decisions/0025-encrypt-rag-chunk-text.md relevant section =="
cat -n docs/decisions/0025-encrypt-rag-chunk-text.md | sed -n '1,90p'echoecho"== tests mentioning chunk dedupe or encryption migration =="
rg -n -C 4 'dedupe_course_chunks|decrypt_if_present|backfill_document_chunks|chunk_hash|chunkid|chunk_id' backend/tests backend/scripts backend/db docs ||true

Repository: SaplingLearn/Sapling

Length of output: 50377


🏁 Script executed:

#!/bin/bashset -euo pipefail
python3 - <<'PY'import base64from cryptography.hazmat.primitives.ciphers.aead import AESGCMkey = bytes.fromhex("00"*32)aesgcm = AESGCM(key)plaintext = "hello world"nonce = b"0" * 12ciphertext = aesgcm.encrypt(nonce, plaintext.encode(), None)stored = base64.b64encode(nonce + ciphertext).decode()wrong_aesgcm = AESGCM(bytes.fromhex("11"*32))try: # This ciphertext is valid-looking base64 + AES nonce length for the current helper wrong_aesgcm.decrypt(nonce, ciphertext, None) decryption_succeeds = Trueexcept Exception as exc: decryption_succeeds = False exc_type = type(exc).__name__is_encrypted_fingerprint = stored.isascii() and base64.b64decode(stored)print({ "wrong_key_decrypt_succeeds": decryption_succeeds, "wrong_key_exception_type": exc_type if not decryption_succeeds else None, "ciphertext_base64_len": len(stored), "ciphertext_base64_is_ascii": stored.isascii(),})PYechoecho"== targeted encryption-related tests and scripts =="
rg -n -C 6 'decrypt_if_present.*fallback|fallback.*raw|ENCRYPTION_KEY|ciphertext|course_chunks|backfill_encryption|dedupe_course_chunks|plan_migration|extracted_text' \
backend tests frontend docs/dev \
--glob '!backend/prompts/**' \
--glob '!backend/db/backfill_encryption.py' \
--glob '!backend/services/encryption.py' \
| head -240

Repository: SaplingLearn/Sapling

Length of output: 283


🌐 Web query:

Python cryptography library AESGCM decrypt wrong key exception

💡 Result:

In the Python cryptography library, the AESGCM.decrypt method raises a cryptography.exceptions.InvalidTag exception when decryption fails [1][2]. This exception is not specific only to an incorrect key; it is raised whenever the authentication tag cannot be validated [1][2]. Because AES-GCM is an authenticated encryption mode, the authentication tag is verified during decryption to ensure the integrity and authenticity of the ciphertext [3]. The verification will fail—triggering the InvalidTag exception—if any of the following are incorrect [1][2]: * The decryption key * The nonce (initialization vector) * The associated data (if any was provided during encryption) * The ciphertext itself (if it has been tampered with or corrupted) The library does not provide a distinct exception for an incorrect key because the nature of authenticated encryption means that from the perspective of the decryption process, any mismatch in these parameters results in an invalid authentication tag [3]. If you encounter this exception, ensure that the key, nonce, and associated data used for decryption match exactly those used during encryption [1][2].

Citations:


Make raw fallback fail closed during migration.

decrypt_if_present treats any decryption failure as legacy plaintext. A wrong key, corrupted ciphertext, or malformed AEAD header can be hashed as row["chunk_text"] by backend/scripts/dedupe_course_chunks.py, delete the real content-addressed row, and leave its metadata attached to ciphertext. Keep the fallback only where plaintext is positively identified (for example, during a migration backfill), and raise on encryption auth/key/format failures.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/decisions/0025-encrypt-rag-chunk-text.md` around lines 52 - 55, Update
decrypt_if_present so decryption authentication, key, and format failures raise
instead of returning the raw value, preventing dedupe_course_chunks from hashing
ciphertext or deleting the wrong content-addressed row. Retain raw fallback only
behind an explicit, positively identified plaintext migration/backfill path, and
preserve normal decryption behavior for valid encrypted values.

- The cost is ~5 AES-GCM decrypts per retrieval (`k=5`). Immaterial.

Ids stay keyed on plaintext because AES-GCM uses a random nonce per call: the
same plaintext encrypts to different ciphertext every time, so ciphertext can
never be a dedup key. `chunk_id(course, text)` remains the merge key, and
re-uploads of identical content still converge on one row.
Comment on lines +58 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Preserve the separate document and catalog ID namespaces.

The existing contract is not one chunk_id(course, text) rule for every row. backend/services/rag_service.py:161-177 documents course::document::text for document rows and course::text for catalog rows. Rewrite this paragraph to require plaintext hashing before encryption while preserving both namespaces. Otherwise an implementation could change existing IDs or create cross-category collisions.

Proposed clarification
-Ids stay keyed on plaintext because AES-GCM uses a random nonce per call: the-same plaintext encrypts to different ciphertext every time, so ciphertext can-never be a dedup key. `chunk_id(course, text)` remains the merge key, and-re-uploads of identical content still converge on one row.+Ids stay keyed on plaintext because AES-GCM uses a random nonce per call: the+same plaintext encrypts to different ciphertext every time, so ciphertext can+never be a dedup key. Preserve the existing per-kind namespaces: document rows+use `chunk_id(course, text)` and catalog rows use `sha256(course::text)`.+Encryption occurs after ID derivation, so identical content still converges+within each row kind.

This follows the ID contract documented in backend/services/rag_service.py:161-177.

📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Ids stay keyed on plaintext because AES-GCM uses a random nonce per call: the
same plaintext encrypts to different ciphertext every time, so ciphertext can
never be a dedup key. `chunk_id(course, text)` remains the merge key, and
re-uploads of identical content still converge on one row.
Ids stay keyed on plaintext because AES-GCM uses a random nonce per call: the
same plaintext encrypts to different ciphertext every time, so ciphertext can
never be a dedup key. Preserve the existing per-kind namespaces: document rows
use `chunk_id(course, text)` and catalog rows use `sha256(course::text)`.
Encryption occurs after ID derivation, so identical content still converges
within each row kind.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/decisions/0025-encrypt-rag-chunk-text.md` around lines 58 - 61, Rewrite
the paragraph to state that IDs are derived from plaintext before encryption and
preserve the separate namespaces: document rows use course::document::text,
while catalog rows use course::text. Ensure the clarification retains stable
existing IDs and prevents cross-category collisions.


**This ADR does not claim chunk confidentiality.** It restores boundary
consistency and removes trivially-readable plaintext. The embedding remains,
and it is the residual exposure.

## Consequences

- (+) The encryption boundary is consistent: the same student text is
protected in `documents.extracted_text` and in the chunks derived from it.
- (+) Retrieval is unaffected — ranking never touched `chunk_text`.
- (+) Unblocks #483. Notes indexing would otherwise write decrypted note
bodies (`notes.body` is encrypted) into a plaintext column, deepening the
very asymmetry this closes.
- (+) A uniform invariant the `ciphertext` oracle can assert, so a future
regression fails a lane instead of sitting unnoticed.
- (−) **Residual exposure: the embedding stays plaintext and is partially
invertible.** This is defense in depth, not confidentiality. Anyone reading
this ADR to answer "is chunk content protected?" must read this line.
- (−) `scripts/dedupe_course_chunks.py:70` re-derives ids from the **stored**
`chunk_text`. Run against encrypted rows it would hash ciphertext and
destroy content-addressing. It must decrypt before hashing, or be retired —
its own docstring calls it a one-time migration.
- (−) A backfill is required for existing rows (the
`db/backfill_encryption.py` precedent). Until it completes the table is
mixed, carried by `decrypt_if_present`'s raw-value fallback.
Comment on lines +75 to +86

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Define the ciphertext-oracle rollout order.

The ADR says the oracle should assert that every chunk_text is ciphertext. It also says legacy plaintext remains until backfill. State that the strict oracle runs only after backfill, and define the expected behavior during the mixed phase. Otherwise the rollout either fails a valid pre-backfill state or weakens the invariant.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/decisions/0025-encrypt-rag-chunk-text.md` around lines 75 - 86, Update
the ADR’s ciphertext-oracle rollout description to state that the strict “every
chunk_text is ciphertext” assertion is enabled only after encryption backfill
completes. Define the mixed-phase behavior explicitly: existing plaintext
remains supported through decrypt_if_present’s raw-value fallback while new or
migrated rows follow the encrypted format, then switch to strict validation once
backfill finishes.

- (−) Catalog chunks pay encryption cost for no privacy benefit. Accepted as
the price of the uniform invariant.

## Implementation (not done here)

Write sites: `services/rag_service.py::index_document_chunks` and
`scripts/ingest_catalog.py`. Read site:
`services/rag_service.py::retrieve_chunks`, decrypting each returned chunk
before `format_rag_context`. Plus the dedupe-script fix above, a backfill, and
extending the `ciphertext` oracle to cover the column.
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions docs/decisions/0025-encrypt-rag-chunk-text.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
# 0025: Encrypt `course_chunks.chunk_text` — restore the encryption boundary, but the embedding stays plaintext

- Status: accepted
- Date: 2026-07-31
- Relates to: #484 (this decision), #483 (blocked on it), #482 (RAG hardening),
#231 (storage/RLS lockdown), migration 0030 (`documents.extracted_text`),
migration 0039 (the vector store)
- Supersedes: none

## Context

#484 names a real asymmetry. `documents.extracted_text` is encrypted (0030)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the issue reference so Markdownlint can parse the paragraph.

Line 12 starts with #484 without a space. Markdownlint reports MD018 because it parses this as an invalid ATX heading. Replace it with Issue #484 names a real asymmetry.

Proposed fix
-#484 names a real asymmetry. `documents.extracted_text` is encrypted (0030)+Issue `#484` names a real asymmetry. `documents.extracted_text` is encrypted (0030)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#484 names a real asymmetry. `documents.extracted_text` is encrypted (0030)
Issue `#484` names a real asymmetry. `documents.extracted_text` is encrypted (0030)
🧰 Tools
🪛 markdownlint-cli2 (0.23.1)

[warning] 12-12: No space after hash on atx style heading

(MD018, no-missing-space-atx)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/decisions/0025-encrypt-rag-chunk-text.md` at line 12, Update the
paragraph beginning with “#484” to begin with “Issue `#484` names a real
asymmetry.” so Markdownlint no longer interprets it as an invalid heading, while
preserving the rest of the paragraph.

Source: Linters/SAST tools

because it is student-uploaded content; `course_chunks.chunk_text` holds *the
same text, chunked*, in plaintext. One column is treated as PII and the other
isn't, for no reason anyone wrote down.

**The issue's stated premise is wrong, and it matters.** It assumes "pgvector
similarity can't run over ciphertext." `match_course_chunks` (0039) computes
`1 - (c.embedding <=> query_embedding)`, orders by `c.embedding <=>
query_embedding`, and filters `WHERE c.embedding IS NOT NULL`. `chunk_text` is
only ever SELECTed as payload — the ranking never reads it. Nothing in the
codebase queries it by content either (no `ILIKE` / `LIKE` / FTS; verified
across `services/`, `routes/`, `scripts/`). So encryption does not block
retrieval at all, and this decision is far cheaper than the issue implies.

But the correction cuts both ways, and this is the part worth recording: **the
reason it's cheap is the reason it's partial.** The `embedding` column cannot
be encrypted — pgvector must compute distance over it — and an embedding is a
lossy but real representation of its source text; embedding-inversion
techniques recover substantial content from vectors alone. Encrypting
`chunk_text` therefore does *not* make the row opaque.

Threat model, for calibration: the backend connects with the service-role key
and RLS locks out `anon`/`authenticated` (#231), so direct table reads imply a
Supabase credential compromise or an insider. `ENCRYPTION_KEY` is a separate
secret held in the app environment, so column encryption genuinely raises the
bar against a database-only compromise — the same bar every other encrypted
column is already set at.

## Decision

Encrypt `chunk_text` for **every** row in `course_chunks` — document *and*
catalog — through the standard `encrypt_if_present` / `decrypt_if_present`
helpers, and compute content-addressed ids on the **plaintext, before
encryption**.

Uniform rather than scoped to document chunks, even though catalog chunks are
public BU course-catalog text with nothing to protect:

- One invariant — "`chunk_text` is always ciphertext" — is assertable by the
existing `ciphertext` e2e oracle. A per-category rule is not.
- `decrypt_if_present` returns the **raw value** when it cannot decrypt. In a
mixed table that makes a genuine decrypt failure indistinguishable from a
legitimately-plaintext catalog row, which is precisely the kind of silent
degradation #482 was about.
Comment on lines +52 to +55

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
rg -n -C 12 \
-e 'def (encrypt_if_present|decrypt_if_present)' \
-e '\bdecrypt_if_present\b' \
-e '\bplan_migration\b' \
-e 'backfill_encryption' \
backend ||true

Repository: SaplingLearn/Sapling

Length of output: 50379


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== relevant file sizes =="
wc -l backend/services/encryption.py backend/scripts/dedupe_course_chunks.py backend/scripts/backfill_document_chunks.py backend/db/backfill_encryption.py docs/decisions/0025-encrypt-rag-chunk-text.md
echoecho"== backend/services/encryption.py =="
cat -n backend/services/encryption.py
echoecho"== backend/scripts/dedupe_course_chunks.py =="
cat -n backend/scripts/dedupe_course_chunks.py
echoecho"== backend/scripts/backfill_document_chunks.py relevant section =="
cat -n backend/scripts/backfill_document_chunks.py | sed -n '1,180p'echoecho"== backend/db/backfill_encryption.py relevant section =="
cat -n backend/db/backfill_encryption.py | sed -n '1,220p'echoecho"== docs/decisions/0025-encrypt-rag-chunk-text.md relevant section =="
cat -n docs/decisions/0025-encrypt-rag-chunk-text.md | sed -n '1,90p'echoecho"== tests mentioning chunk dedupe or encryption migration =="
rg -n -C 4 'dedupe_course_chunks|decrypt_if_present|backfill_document_chunks|chunk_hash|chunkid|chunk_id' backend/tests backend/scripts backend/db docs ||true

Repository: SaplingLearn/Sapling

Length of output: 50377


🏁 Script executed:

#!/bin/bashset -euo pipefail
python3 - <<'PY'import base64from cryptography.hazmat.primitives.ciphers.aead import AESGCMkey = bytes.fromhex("00"*32)aesgcm = AESGCM(key)plaintext = "hello world"nonce = b"0" * 12ciphertext = aesgcm.encrypt(nonce, plaintext.encode(), None)stored = base64.b64encode(nonce + ciphertext).decode()wrong_aesgcm = AESGCM(bytes.fromhex("11"*32))try: # This ciphertext is valid-looking base64 + AES nonce length for the current helper wrong_aesgcm.decrypt(nonce, ciphertext, None) decryption_succeeds = Trueexcept Exception as exc: decryption_succeeds = False exc_type = type(exc).__name__is_encrypted_fingerprint = stored.isascii() and base64.b64decode(stored)print({ "wrong_key_decrypt_succeeds": decryption_succeeds, "wrong_key_exception_type": exc_type if not decryption_succeeds else None, "ciphertext_base64_len": len(stored), "ciphertext_base64_is_ascii": stored.isascii(),})PYechoecho"== targeted encryption-related tests and scripts =="
rg -n -C 6 'decrypt_if_present.*fallback|fallback.*raw|ENCRYPTION_KEY|ciphertext|course_chunks|backfill_encryption|dedupe_course_chunks|plan_migration|extracted_text' \
backend tests frontend docs/dev \
--glob '!backend/prompts/**' \
--glob '!backend/db/backfill_encryption.py' \
--glob '!backend/services/encryption.py' \
| head -240

Repository: SaplingLearn/Sapling

Length of output: 283


🌐 Web query:

Python cryptography library AESGCM decrypt wrong key exception

💡 Result:

In the Python cryptography library, the AESGCM.decrypt method raises a cryptography.exceptions.InvalidTag exception when decryption fails [1][2]. This exception is not specific only to an incorrect key; it is raised whenever the authentication tag cannot be validated [1][2]. Because AES-GCM is an authenticated encryption mode, the authentication tag is verified during decryption to ensure the integrity and authenticity of the ciphertext [3]. The verification will fail—triggering the InvalidTag exception—if any of the following are incorrect [1][2]: * The decryption key * The nonce (initialization vector) * The associated data (if any was provided during encryption) * The ciphertext itself (if it has been tampered with or corrupted) The library does not provide a distinct exception for an incorrect key because the nature of authenticated encryption means that from the perspective of the decryption process, any mismatch in these parameters results in an invalid authentication tag [3]. If you encounter this exception, ensure that the key, nonce, and associated data used for decryption match exactly those used during encryption [1][2].

Citations:


Make raw fallback fail closed during migration.

decrypt_if_present treats any decryption failure as legacy plaintext. A wrong key, corrupted ciphertext, or malformed AEAD header can be hashed as row["chunk_text"] by backend/scripts/dedupe_course_chunks.py, delete the real content-addressed row, and leave its metadata attached to ciphertext. Keep the fallback only where plaintext is positively identified (for example, during a migration backfill), and raise on encryption auth/key/format failures.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/decisions/0025-encrypt-rag-chunk-text.md` around lines 52 - 55, Update
decrypt_if_present so decryption authentication, key, and format failures raise
instead of returning the raw value, preventing dedupe_course_chunks from hashing
ciphertext or deleting the wrong content-addressed row. Retain raw fallback only
behind an explicit, positively identified plaintext migration/backfill path, and
preserve normal decryption behavior for valid encrypted values.

- The cost is ~5 AES-GCM decrypts per retrieval (`k=5`). Immaterial.

Ids stay keyed on plaintext because AES-GCM uses a random nonce per call: the
same plaintext encrypts to different ciphertext every time, so ciphertext can
never be a dedup key. `chunk_id(course, text)` remains the merge key, and
re-uploads of identical content still converge on one row.
Comment on lines +58 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Preserve the separate document and catalog ID namespaces.

The existing contract is not one chunk_id(course, text) rule for every row. backend/services/rag_service.py:161-177 documents course::document::text for document rows and course::text for catalog rows. Rewrite this paragraph to require plaintext hashing before encryption while preserving both namespaces. Otherwise an implementation could change existing IDs or create cross-category collisions.

Proposed clarification
-Ids stay keyed on plaintext because AES-GCM uses a random nonce per call: the-same plaintext encrypts to different ciphertext every time, so ciphertext can-never be a dedup key. `chunk_id(course, text)` remains the merge key, and-re-uploads of identical content still converge on one row.+Ids stay keyed on plaintext because AES-GCM uses a random nonce per call: the+same plaintext encrypts to different ciphertext every time, so ciphertext can+never be a dedup key. Preserve the existing per-kind namespaces: document rows+use `chunk_id(course, text)` and catalog rows use `sha256(course::text)`.+Encryption occurs after ID derivation, so identical content still converges+within each row kind.

This follows the ID contract documented in backend/services/rag_service.py:161-177.

📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Ids stay keyed on plaintext because AES-GCM uses a random nonce per call: the
same plaintext encrypts to different ciphertext every time, so ciphertext can
never be a dedup key. `chunk_id(course, text)` remains the merge key, and
re-uploads of identical content still converge on one row.
Ids stay keyed on plaintext because AES-GCM uses a random nonce per call: the
same plaintext encrypts to different ciphertext every time, so ciphertext can
never be a dedup key. Preserve the existing per-kind namespaces: document rows
use `chunk_id(course, text)` and catalog rows use `sha256(course::text)`.
Encryption occurs after ID derivation, so identical content still converges
within each row kind.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/decisions/0025-encrypt-rag-chunk-text.md` around lines 58 - 61, Rewrite
the paragraph to state that IDs are derived from plaintext before encryption and
preserve the separate namespaces: document rows use course::document::text,
while catalog rows use course::text. Ensure the clarification retains stable
existing IDs and prevents cross-category collisions.


**This ADR does not claim chunk confidentiality.** It restores boundary
consistency and removes trivially-readable plaintext. The embedding remains,
and it is the residual exposure.

## Consequences

- (+) The encryption boundary is consistent: the same student text is
protected in `documents.extracted_text` and in the chunks derived from it.
- (+) Retrieval is unaffected — ranking never touched `chunk_text`.
- (+) Unblocks #483. Notes indexing would otherwise write decrypted note
bodies (`notes.body` is encrypted) into a plaintext column, deepening the
very asymmetry this closes.
- (+) A uniform invariant the `ciphertext` oracle can assert, so a future
regression fails a lane instead of sitting unnoticed.
- (−) **Residual exposure: the embedding stays plaintext and is partially
invertible.** This is defense in depth, not confidentiality. Anyone reading
this ADR to answer "is chunk content protected?" must read this line.
- (−) `scripts/dedupe_course_chunks.py:70` re-derives ids from the **stored**
`chunk_text`. Run against encrypted rows it would hash ciphertext and
destroy content-addressing. It must decrypt before hashing, or be retired —
its own docstring calls it a one-time migration.
- (−) A backfill is required for existing rows (the
`db/backfill_encryption.py` precedent). Until it completes the table is
mixed, carried by `decrypt_if_present`'s raw-value fallback.
Comment on lines +75 to +86

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Define the ciphertext-oracle rollout order.

The ADR says the oracle should assert that every chunk_text is ciphertext. It also says legacy plaintext remains until backfill. State that the strict oracle runs only after backfill, and define the expected behavior during the mixed phase. Otherwise the rollout either fails a valid pre-backfill state or weakens the invariant.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/decisions/0025-encrypt-rag-chunk-text.md` around lines 75 - 86, Update
the ADR’s ciphertext-oracle rollout description to state that the strict “every
chunk_text is ciphertext” assertion is enabled only after encryption backfill
completes. Define the mixed-phase behavior explicitly: existing plaintext
remains supported through decrypt_if_present’s raw-value fallback while new or
migrated rows follow the encrypted format, then switch to strict validation once
backfill finishes.

- (−) Catalog chunks pay encryption cost for no privacy benefit. Accepted as
the price of the uniform invariant.

## Implementation (not done here)

Write sites: `services/rag_service.py::index_document_chunks` and
`scripts/ingest_catalog.py`. Read site:
`services/rag_service.py::retrieve_chunks`, decrypting each returned chunk
before `format_rag_context`. Plus the dedupe-script fix above, a backfill, and
extending the `ciphertext` oracle to cover the column.
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions docs/decisions/0025-encrypt-rag-chunk-text.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
# 0025: Encrypt `course_chunks.chunk_text` — restore the encryption boundary, but the embedding stays plaintext

- Status: accepted
- Date: 2026-07-31
- Relates to: #484 (this decision), #483 (blocked on it), #482 (RAG hardening),
#231 (storage/RLS lockdown), migration 0030 (`documents.extracted_text`),
migration 0039 (the vector store)
- Supersedes: none

## Context

#484 names a real asymmetry. `documents.extracted_text` is encrypted (0030)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the issue reference so Markdownlint can parse the paragraph.

Line 12 starts with #484 without a space. Markdownlint reports MD018 because it parses this as an invalid ATX heading. Replace it with Issue #484 names a real asymmetry.

Proposed fix
-#484 names a real asymmetry. `documents.extracted_text` is encrypted (0030)+Issue `#484` names a real asymmetry. `documents.extracted_text` is encrypted (0030)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#484 names a real asymmetry. `documents.extracted_text` is encrypted (0030)
Issue `#484` names a real asymmetry. `documents.extracted_text` is encrypted (0030)
🧰 Tools
🪛 markdownlint-cli2 (0.23.1)

[warning] 12-12: No space after hash on atx style heading

(MD018, no-missing-space-atx)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/decisions/0025-encrypt-rag-chunk-text.md` at line 12, Update the
paragraph beginning with “#484” to begin with “Issue `#484` names a real
asymmetry.” so Markdownlint no longer interprets it as an invalid heading, while
preserving the rest of the paragraph.

Source: Linters/SAST tools

because it is student-uploaded content; `course_chunks.chunk_text` holds *the
same text, chunked*, in plaintext. One column is treated as PII and the other
isn't, for no reason anyone wrote down.

**The issue's stated premise is wrong, and it matters.** It assumes "pgvector
similarity can't run over ciphertext." `match_course_chunks` (0039) computes
`1 - (c.embedding <=> query_embedding)`, orders by `c.embedding <=>
query_embedding`, and filters `WHERE c.embedding IS NOT NULL`. `chunk_text` is
only ever SELECTed as payload — the ranking never reads it. Nothing in the
codebase queries it by content either (no `ILIKE` / `LIKE` / FTS; verified
across `services/`, `routes/`, `scripts/`). So encryption does not block
retrieval at all, and this decision is far cheaper than the issue implies.

But the correction cuts both ways, and this is the part worth recording: **the
reason it's cheap is the reason it's partial.** The `embedding` column cannot
be encrypted — pgvector must compute distance over it — and an embedding is a
lossy but real representation of its source text; embedding-inversion
techniques recover substantial content from vectors alone. Encrypting
`chunk_text` therefore does *not* make the row opaque.

Threat model, for calibration: the backend connects with the service-role key
and RLS locks out `anon`/`authenticated` (#231), so direct table reads imply a
Supabase credential compromise or an insider. `ENCRYPTION_KEY` is a separate
secret held in the app environment, so column encryption genuinely raises the
bar against a database-only compromise — the same bar every other encrypted
column is already set at.

## Decision

Encrypt `chunk_text` for **every** row in `course_chunks` — document *and*
catalog — through the standard `encrypt_if_present` / `decrypt_if_present`
helpers, and compute content-addressed ids on the **plaintext, before
encryption**.

Uniform rather than scoped to document chunks, even though catalog chunks are
public BU course-catalog text with nothing to protect:

- One invariant — "`chunk_text` is always ciphertext" — is assertable by the
existing `ciphertext` e2e oracle. A per-category rule is not.
- `decrypt_if_present` returns the **raw value** when it cannot decrypt. In a
mixed table that makes a genuine decrypt failure indistinguishable from a
legitimately-plaintext catalog row, which is precisely the kind of silent
degradation #482 was about.
Comment on lines +52 to +55

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bashset -euo pipefail
rg -n -C 12 \
-e 'def (encrypt_if_present|decrypt_if_present)' \
-e '\bdecrypt_if_present\b' \
-e '\bplan_migration\b' \
-e 'backfill_encryption' \
backend ||true

Repository: SaplingLearn/Sapling

Length of output: 50379


🏁 Script executed:

#!/bin/bashset -euo pipefail
echo"== relevant file sizes =="
wc -l backend/services/encryption.py backend/scripts/dedupe_course_chunks.py backend/scripts/backfill_document_chunks.py backend/db/backfill_encryption.py docs/decisions/0025-encrypt-rag-chunk-text.md
echoecho"== backend/services/encryption.py =="
cat -n backend/services/encryption.py
echoecho"== backend/scripts/dedupe_course_chunks.py =="
cat -n backend/scripts/dedupe_course_chunks.py
echoecho"== backend/scripts/backfill_document_chunks.py relevant section =="
cat -n backend/scripts/backfill_document_chunks.py | sed -n '1,180p'echoecho"== backend/db/backfill_encryption.py relevant section =="
cat -n backend/db/backfill_encryption.py | sed -n '1,220p'echoecho"== docs/decisions/0025-encrypt-rag-chunk-text.md relevant section =="
cat -n docs/decisions/0025-encrypt-rag-chunk-text.md | sed -n '1,90p'echoecho"== tests mentioning chunk dedupe or encryption migration =="
rg -n -C 4 'dedupe_course_chunks|decrypt_if_present|backfill_document_chunks|chunk_hash|chunkid|chunk_id' backend/tests backend/scripts backend/db docs ||true

Repository: SaplingLearn/Sapling

Length of output: 50377


🏁 Script executed:

#!/bin/bashset -euo pipefail
python3 - <<'PY'import base64from cryptography.hazmat.primitives.ciphers.aead import AESGCMkey = bytes.fromhex("00"*32)aesgcm = AESGCM(key)plaintext = "hello world"nonce = b"0" * 12ciphertext = aesgcm.encrypt(nonce, plaintext.encode(), None)stored = base64.b64encode(nonce + ciphertext).decode()wrong_aesgcm = AESGCM(bytes.fromhex("11"*32))try: # This ciphertext is valid-looking base64 + AES nonce length for the current helper wrong_aesgcm.decrypt(nonce, ciphertext, None) decryption_succeeds = Trueexcept Exception as exc: decryption_succeeds = False exc_type = type(exc).__name__is_encrypted_fingerprint = stored.isascii() and base64.b64decode(stored)print({ "wrong_key_decrypt_succeeds": decryption_succeeds, "wrong_key_exception_type": exc_type if not decryption_succeeds else None, "ciphertext_base64_len": len(stored), "ciphertext_base64_is_ascii": stored.isascii(),})PYechoecho"== targeted encryption-related tests and scripts =="
rg -n -C 6 'decrypt_if_present.*fallback|fallback.*raw|ENCRYPTION_KEY|ciphertext|course_chunks|backfill_encryption|dedupe_course_chunks|plan_migration|extracted_text' \
backend tests frontend docs/dev \
--glob '!backend/prompts/**' \
--glob '!backend/db/backfill_encryption.py' \
--glob '!backend/services/encryption.py' \
| head -240

Repository: SaplingLearn/Sapling

Length of output: 283


🌐 Web query:

Python cryptography library AESGCM decrypt wrong key exception

💡 Result:

In the Python cryptography library, the AESGCM.decrypt method raises a cryptography.exceptions.InvalidTag exception when decryption fails [1][2]. This exception is not specific only to an incorrect key; it is raised whenever the authentication tag cannot be validated [1][2]. Because AES-GCM is an authenticated encryption mode, the authentication tag is verified during decryption to ensure the integrity and authenticity of the ciphertext [3]. The verification will fail—triggering the InvalidTag exception—if any of the following are incorrect [1][2]: * The decryption key * The nonce (initialization vector) * The associated data (if any was provided during encryption) * The ciphertext itself (if it has been tampered with or corrupted) The library does not provide a distinct exception for an incorrect key because the nature of authenticated encryption means that from the perspective of the decryption process, any mismatch in these parameters results in an invalid authentication tag [3]. If you encounter this exception, ensure that the key, nonce, and associated data used for decryption match exactly those used during encryption [1][2].

Citations:


Make raw fallback fail closed during migration.

decrypt_if_present treats any decryption failure as legacy plaintext. A wrong key, corrupted ciphertext, or malformed AEAD header can be hashed as row["chunk_text"] by backend/scripts/dedupe_course_chunks.py, delete the real content-addressed row, and leave its metadata attached to ciphertext. Keep the fallback only where plaintext is positively identified (for example, during a migration backfill), and raise on encryption auth/key/format failures.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/decisions/0025-encrypt-rag-chunk-text.md` around lines 52 - 55, Update
decrypt_if_present so decryption authentication, key, and format failures raise
instead of returning the raw value, preventing dedupe_course_chunks from hashing
ciphertext or deleting the wrong content-addressed row. Retain raw fallback only
behind an explicit, positively identified plaintext migration/backfill path, and
preserve normal decryption behavior for valid encrypted values.

- The cost is ~5 AES-GCM decrypts per retrieval (`k=5`). Immaterial.

Ids stay keyed on plaintext because AES-GCM uses a random nonce per call: the
same plaintext encrypts to different ciphertext every time, so ciphertext can
never be a dedup key. `chunk_id(course, text)` remains the merge key, and
re-uploads of identical content still converge on one row.
Comment on lines +58 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Preserve the separate document and catalog ID namespaces.

The existing contract is not one chunk_id(course, text) rule for every row. backend/services/rag_service.py:161-177 documents course::document::text for document rows and course::text for catalog rows. Rewrite this paragraph to require plaintext hashing before encryption while preserving both namespaces. Otherwise an implementation could change existing IDs or create cross-category collisions.

Proposed clarification
-Ids stay keyed on plaintext because AES-GCM uses a random nonce per call: the-same plaintext encrypts to different ciphertext every time, so ciphertext can-never be a dedup key. `chunk_id(course, text)` remains the merge key, and-re-uploads of identical content still converge on one row.+Ids stay keyed on plaintext because AES-GCM uses a random nonce per call: the+same plaintext encrypts to different ciphertext every time, so ciphertext can+never be a dedup key. Preserve the existing per-kind namespaces: document rows+use `chunk_id(course, text)` and catalog rows use `sha256(course::text)`.+Encryption occurs after ID derivation, so identical content still converges+within each row kind.

This follows the ID contract documented in backend/services/rag_service.py:161-177.

📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Ids stay keyed on plaintext because AES-GCM uses a random nonce per call: the
same plaintext encrypts to different ciphertext every time, so ciphertext can
never be a dedup key. `chunk_id(course, text)` remains the merge key, and
re-uploads of identical content still converge on one row.
Ids stay keyed on plaintext because AES-GCM uses a random nonce per call: the
same plaintext encrypts to different ciphertext every time, so ciphertext can
never be a dedup key. Preserve the existing per-kind namespaces: document rows
use `chunk_id(course, text)` and catalog rows use `sha256(course::text)`.
Encryption occurs after ID derivation, so identical content still converges
within each row kind.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/decisions/0025-encrypt-rag-chunk-text.md` around lines 58 - 61, Rewrite
the paragraph to state that IDs are derived from plaintext before encryption and
preserve the separate namespaces: document rows use course::document::text,
while catalog rows use course::text. Ensure the clarification retains stable
existing IDs and prevents cross-category collisions.


**This ADR does not claim chunk confidentiality.** It restores boundary
consistency and removes trivially-readable plaintext. The embedding remains,
and it is the residual exposure.

## Consequences

- (+) The encryption boundary is consistent: the same student text is
protected in `documents.extracted_text` and in the chunks derived from it.
- (+) Retrieval is unaffected — ranking never touched `chunk_text`.
- (+) Unblocks #483. Notes indexing would otherwise write decrypted note
bodies (`notes.body` is encrypted) into a plaintext column, deepening the
very asymmetry this closes.
- (+) A uniform invariant the `ciphertext` oracle can assert, so a future
regression fails a lane instead of sitting unnoticed.
- (−) **Residual exposure: the embedding stays plaintext and is partially
invertible.** This is defense in depth, not confidentiality. Anyone reading
this ADR to answer "is chunk content protected?" must read this line.
- (−) `scripts/dedupe_course_chunks.py:70` re-derives ids from the **stored**
`chunk_text`. Run against encrypted rows it would hash ciphertext and
destroy content-addressing. It must decrypt before hashing, or be retired —
its own docstring calls it a one-time migration.
- (−) A backfill is required for existing rows (the
`db/backfill_encryption.py` precedent). Until it completes the table is
mixed, carried by `decrypt_if_present`'s raw-value fallback.
Comment on lines +75 to +86

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Define the ciphertext-oracle rollout order.

The ADR says the oracle should assert that every chunk_text is ciphertext. It also says legacy plaintext remains until backfill. State that the strict oracle runs only after backfill, and define the expected behavior during the mixed phase. Otherwise the rollout either fails a valid pre-backfill state or weakens the invariant.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/decisions/0025-encrypt-rag-chunk-text.md` around lines 75 - 86, Update
the ADR’s ciphertext-oracle rollout description to state that the strict “every
chunk_text is ciphertext” assertion is enabled only after encryption backfill
completes. Define the mixed-phase behavior explicitly: existing plaintext
remains supported through decrypt_if_present’s raw-value fallback while new or
migrated rows follow the encrypted format, then switch to strict validation once
backfill finishes.

- (−) Catalog chunks pay encryption cost for no privacy benefit. Accepted as
the price of the uniform invariant.

## Implementation (not done here)

Write sites: `services/rag_service.py::index_document_chunks` and
`scripts/ingest_catalog.py`. Read site:
`services/rag_service.py::retrieve_chunks`, decrypting each returned chunk
before `format_rag_context`. Plus the dedupe-script fix above, a backfill, and
extending the `ciphertext` oracle to cover the column.
Loading