docs(adr): 0025 — encrypt course_chunks.chunk_text (#484) - #503

Merged
AndresL230 merged 1 commit into
mainfrom
docs/0025-chunk-text-encryption-adr
Jul 31, 2026
Merged

docs(adr): 0025 — encrypt course_chunks.chunk_text (#484)#503
AndresL230 merged 1 commit into
mainfrom
docs/0025-chunk-text-encryption-adr

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Part of #484. Records the decision with the reasoning corrected.

The premise in the issue is wrong

pgvector similarity can't run over ciphertext

match_course_chunks (migration 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 SELECTed as payload — the ranking never reads it. Nothing queries it by content either (no ILIKE/LIKE/FTS anywhere in services/, routes/, scripts/).

So encryption doesn't block retrieval, and this is cheaper than the issue implies.

But the same fact makes it partial — and that's what the ADR exists to record

The embedding column cannot be encrypted, because pgvector must compute distance over it. An embedding is a lossy but real representation of its source text, and embedding-inversion recovers substantial content from vectors alone.

So encrypting chunk_text does not make the row opaque. Without that written down, the next person reads "chunk_text is encrypted" and concludes chunk content is confidential. It isn't. The ADR states this as a consequence rather than burying it.

What was decided

Encrypt uniformly — document and catalog chunks — with ids still computed on plaintext.

  • Uniform, because one invariant ("chunk_text is always ciphertext") is assertable by the existing ciphertext oracle, and because decrypt_if_present returns the raw value on failure — in a mixed table that makes a genuine decrypt failure indistinguishable from a legitimately-plaintext catalog row.
  • Ids on plaintext, because AES-GCM uses a random nonce: identical text encrypts differently every time, so ciphertext can never be a dedup key.

One trap found while verifying

scripts/dedupe_course_chunks.py:70 re-derives chunk ids from the storedchunk_text. Run against encrypted rows it would hash ciphertext and destroy content-addressing. It must decrypt first or be retired — its docstring calls it a one-time migration.

Scope

ADR only; no code. Implementation sites are listed at the bottom of the file. #483 is blocked on this decision, so landing the decision unblocks it.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Documentation
    • Added an architecture decision record documenting encryption for stored course content.
    • Clarified encryption coverage, retrieval compatibility, migration requirements, deduplication considerations, and remaining embedding exposure.

Records the decision Andres already made, with the reasoning corrected.
The issue's premise ("pgvector similarity can't run over ciphertext") is
wrong: match_course_chunks ranks on 1 - (embedding <=> query_embedding) and
only SELECTs chunk_text as payload, and nothing queries it by content. So
encryption doesn't block retrieval and the decision is cheaper than stated.
But the same fact makes it partial, which is the part worth recording: the
embedding can't be encrypted (pgvector computes distance over it) and is
partially invertible back to its source text. So this restores boundary
consistency with documents.extracted_text — it does not make chunks
confidential, and the ADR says so explicitly rather than letting a future
reader assume otherwise.
Decided uniform (document AND catalog chunks) so the invariant is assertable
by the existing ciphertext oracle, and because decrypt_if_present's
raw-value fallback would make a real decrypt failure indistinguishable from a
legitimately-plaintext catalog row in a mixed table.
Ids stay computed on plaintext: AES-GCM's random nonce means identical text
encrypts differently every time, so ciphertext can never be a dedup key.
Flagged that scripts/dedupe_course_chunks.py:70 re-derives ids from the
STORED text and would destroy content-addressing if run against encrypted
rows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 31, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Added ADR 0025. It defines encryption for course_chunks.chunk_text, plaintext embeddings for retrieval, content ID handling, migration requirements, decryption fallback, deduplication updates, and residual embedding exposure.

Changes

RAG Chunk Text Encryption

Layer / File(s)Summary
Encryption decision and migration rules
docs/decisions/0025-encrypt-rag-chunk-text.md
The ADR defines encryption and decryption behavior, plaintext-based IDs, retrieval compatibility, mixed-state backfills, deduplication changes, affected write and read paths, and threat-model limits.

Estimated code review effort: 1 (Trivial) | ~3 minutes

Possibly related issues

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the ADR and its main change: encrypting course_chunks.chunk_text.
Description check✅ PassedThe description clearly explains the decision, rationale, scope, related issue, and review considerations, but omits the template's explicit testing checklist.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch docs/0025-chunk-text-encryption-adr

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingc7b3c81Commit Preview URL

Branch Preview URL
Jul 31 2026, 07:44 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Prompt for all review comments with 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.
Inline comments:
In `@docs/decisions/0025-encrypt-rag-chunk-text.md`:
- Around line 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.
- Around line 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.
- 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.
- Around line 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.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 231e8094-2fdf-4b6f-a05f-6ffafc4f022f

📥 Commits

Reviewing files that changed from the base of the PR and between ac5e21e and c7b3c81.

📒 Files selected for processing (1)
  • docs/decisions/0025-encrypt-rag-chunk-text.md


## 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

Comment on lines +52 to +55
- `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.

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.

Comment on lines +58 to +61
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.

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.

Comment on lines +75 to +86
- (+) 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.

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.

@AndresL230
AndresL230 merged commit 17353fc into mainJul 31, 2026
7 checks passed
@AndresL230
AndresL230 deleted the docs/0025-chunk-text-encryption-adr branch August 2, 2026 18:29
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, '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

docs(adr): 0025 — encrypt course_chunks.chunk_text (#484) - #503

Merged
AndresL230 merged 1 commit into
mainfrom
docs/0025-chunk-text-encryption-adr
Jul 31, 2026
Merged

docs(adr): 0025 — encrypt course_chunks.chunk_text (#484)#503
AndresL230 merged 1 commit into
mainfrom
docs/0025-chunk-text-encryption-adr

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Part of #484. Records the decision with the reasoning corrected.

The premise in the issue is wrong

pgvector similarity can't run over ciphertext

match_course_chunks (migration 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 SELECTed as payload — the ranking never reads it. Nothing queries it by content either (no ILIKE/LIKE/FTS anywhere in services/, routes/, scripts/).

So encryption doesn't block retrieval, and this is cheaper than the issue implies.

But the same fact makes it partial — and that's what the ADR exists to record

The embedding column cannot be encrypted, because pgvector must compute distance over it. An embedding is a lossy but real representation of its source text, and embedding-inversion recovers substantial content from vectors alone.

So encrypting chunk_text does not make the row opaque. Without that written down, the next person reads "chunk_text is encrypted" and concludes chunk content is confidential. It isn't. The ADR states this as a consequence rather than burying it.

What was decided

Encrypt uniformly — document and catalog chunks — with ids still computed on plaintext.

  • Uniform, because one invariant ("chunk_text is always ciphertext") is assertable by the existing ciphertext oracle, and because decrypt_if_present returns the raw value on failure — in a mixed table that makes a genuine decrypt failure indistinguishable from a legitimately-plaintext catalog row.
  • Ids on plaintext, because AES-GCM uses a random nonce: identical text encrypts differently every time, so ciphertext can never be a dedup key.

One trap found while verifying

scripts/dedupe_course_chunks.py:70 re-derives chunk ids from the storedchunk_text. Run against encrypted rows it would hash ciphertext and destroy content-addressing. It must decrypt first or be retired — its docstring calls it a one-time migration.

Scope

ADR only; no code. Implementation sites are listed at the bottom of the file. #483 is blocked on this decision, so landing the decision unblocks it.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Documentation
    • Added an architecture decision record documenting encryption for stored course content.
    • Clarified encryption coverage, retrieval compatibility, migration requirements, deduplication considerations, and remaining embedding exposure.

Records the decision Andres already made, with the reasoning corrected.
The issue's premise ("pgvector similarity can't run over ciphertext") is
wrong: match_course_chunks ranks on 1 - (embedding <=> query_embedding) and
only SELECTs chunk_text as payload, and nothing queries it by content. So
encryption doesn't block retrieval and the decision is cheaper than stated.
But the same fact makes it partial, which is the part worth recording: the
embedding can't be encrypted (pgvector computes distance over it) and is
partially invertible back to its source text. So this restores boundary
consistency with documents.extracted_text — it does not make chunks
confidential, and the ADR says so explicitly rather than letting a future
reader assume otherwise.
Decided uniform (document AND catalog chunks) so the invariant is assertable
by the existing ciphertext oracle, and because decrypt_if_present's
raw-value fallback would make a real decrypt failure indistinguishable from a
legitimately-plaintext catalog row in a mixed table.
Ids stay computed on plaintext: AES-GCM's random nonce means identical text
encrypts differently every time, so ciphertext can never be a dedup key.
Flagged that scripts/dedupe_course_chunks.py:70 re-derives ids from the
STORED text and would destroy content-addressing if run against encrypted
rows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 31, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Added ADR 0025. It defines encryption for course_chunks.chunk_text, plaintext embeddings for retrieval, content ID handling, migration requirements, decryption fallback, deduplication updates, and residual embedding exposure.

Changes

RAG Chunk Text Encryption

Layer / File(s)Summary
Encryption decision and migration rules
docs/decisions/0025-encrypt-rag-chunk-text.md
The ADR defines encryption and decryption behavior, plaintext-based IDs, retrieval compatibility, mixed-state backfills, deduplication changes, affected write and read paths, and threat-model limits.

Estimated code review effort: 1 (Trivial) | ~3 minutes

Possibly related issues

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the ADR and its main change: encrypting course_chunks.chunk_text.
Description check✅ PassedThe description clearly explains the decision, rationale, scope, related issue, and review considerations, but omits the template's explicit testing checklist.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch docs/0025-chunk-text-encryption-adr

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingc7b3c81Commit Preview URL

Branch Preview URL
Jul 31 2026, 07:44 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Prompt for all review comments with 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.
Inline comments:
In `@docs/decisions/0025-encrypt-rag-chunk-text.md`:
- Around line 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.
- Around line 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.
- 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.
- Around line 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.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 231e8094-2fdf-4b6f-a05f-6ffafc4f022f

📥 Commits

Reviewing files that changed from the base of the PR and between ac5e21e and c7b3c81.

📒 Files selected for processing (1)
  • docs/decisions/0025-encrypt-rag-chunk-text.md


## 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

Comment on lines +52 to +55
- `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.

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.

Comment on lines +58 to +61
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.

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.

Comment on lines +75 to +86
- (+) 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.

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.

@AndresL230
AndresL230 merged commit 17353fc into mainJul 31, 2026
7 checks passed
@AndresL230
AndresL230 deleted the docs/0025-chunk-text-encryption-adr branch August 2, 2026 18:29
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, '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

docs(adr): 0025 — encrypt course_chunks.chunk_text (#484) - #503

Merged
AndresL230 merged 1 commit into
mainfrom
docs/0025-chunk-text-encryption-adr
Jul 31, 2026
Merged

docs(adr): 0025 — encrypt course_chunks.chunk_text (#484)#503
AndresL230 merged 1 commit into
mainfrom
docs/0025-chunk-text-encryption-adr

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Part of #484. Records the decision with the reasoning corrected.

The premise in the issue is wrong

pgvector similarity can't run over ciphertext

match_course_chunks (migration 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 SELECTed as payload — the ranking never reads it. Nothing queries it by content either (no ILIKE/LIKE/FTS anywhere in services/, routes/, scripts/).

So encryption doesn't block retrieval, and this is cheaper than the issue implies.

But the same fact makes it partial — and that's what the ADR exists to record

The embedding column cannot be encrypted, because pgvector must compute distance over it. An embedding is a lossy but real representation of its source text, and embedding-inversion recovers substantial content from vectors alone.

So encrypting chunk_text does not make the row opaque. Without that written down, the next person reads "chunk_text is encrypted" and concludes chunk content is confidential. It isn't. The ADR states this as a consequence rather than burying it.

What was decided

Encrypt uniformly — document and catalog chunks — with ids still computed on plaintext.

  • Uniform, because one invariant ("chunk_text is always ciphertext") is assertable by the existing ciphertext oracle, and because decrypt_if_present returns the raw value on failure — in a mixed table that makes a genuine decrypt failure indistinguishable from a legitimately-plaintext catalog row.
  • Ids on plaintext, because AES-GCM uses a random nonce: identical text encrypts differently every time, so ciphertext can never be a dedup key.

One trap found while verifying

scripts/dedupe_course_chunks.py:70 re-derives chunk ids from the storedchunk_text. Run against encrypted rows it would hash ciphertext and destroy content-addressing. It must decrypt first or be retired — its docstring calls it a one-time migration.

Scope

ADR only; no code. Implementation sites are listed at the bottom of the file. #483 is blocked on this decision, so landing the decision unblocks it.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Documentation
    • Added an architecture decision record documenting encryption for stored course content.
    • Clarified encryption coverage, retrieval compatibility, migration requirements, deduplication considerations, and remaining embedding exposure.

Records the decision Andres already made, with the reasoning corrected.
The issue's premise ("pgvector similarity can't run over ciphertext") is
wrong: match_course_chunks ranks on 1 - (embedding <=> query_embedding) and
only SELECTs chunk_text as payload, and nothing queries it by content. So
encryption doesn't block retrieval and the decision is cheaper than stated.
But the same fact makes it partial, which is the part worth recording: the
embedding can't be encrypted (pgvector computes distance over it) and is
partially invertible back to its source text. So this restores boundary
consistency with documents.extracted_text — it does not make chunks
confidential, and the ADR says so explicitly rather than letting a future
reader assume otherwise.
Decided uniform (document AND catalog chunks) so the invariant is assertable
by the existing ciphertext oracle, and because decrypt_if_present's
raw-value fallback would make a real decrypt failure indistinguishable from a
legitimately-plaintext catalog row in a mixed table.
Ids stay computed on plaintext: AES-GCM's random nonce means identical text
encrypts differently every time, so ciphertext can never be a dedup key.
Flagged that scripts/dedupe_course_chunks.py:70 re-derives ids from the
STORED text and would destroy content-addressing if run against encrypted
rows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 31, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Added ADR 0025. It defines encryption for course_chunks.chunk_text, plaintext embeddings for retrieval, content ID handling, migration requirements, decryption fallback, deduplication updates, and residual embedding exposure.

Changes

RAG Chunk Text Encryption

Layer / File(s)Summary
Encryption decision and migration rules
docs/decisions/0025-encrypt-rag-chunk-text.md
The ADR defines encryption and decryption behavior, plaintext-based IDs, retrieval compatibility, mixed-state backfills, deduplication changes, affected write and read paths, and threat-model limits.

Estimated code review effort: 1 (Trivial) | ~3 minutes

Possibly related issues

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the ADR and its main change: encrypting course_chunks.chunk_text.
Description check✅ PassedThe description clearly explains the decision, rationale, scope, related issue, and review considerations, but omits the template's explicit testing checklist.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch docs/0025-chunk-text-encryption-adr

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingc7b3c81Commit Preview URL

Branch Preview URL
Jul 31 2026, 07:44 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Prompt for all review comments with 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.
Inline comments:
In `@docs/decisions/0025-encrypt-rag-chunk-text.md`:
- Around line 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.
- Around line 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.
- 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.
- Around line 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.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 231e8094-2fdf-4b6f-a05f-6ffafc4f022f

📥 Commits

Reviewing files that changed from the base of the PR and between ac5e21e and c7b3c81.

📒 Files selected for processing (1)
  • docs/decisions/0025-encrypt-rag-chunk-text.md


## 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

Comment on lines +52 to +55
- `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.

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.

Comment on lines +58 to +61
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.

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.

Comment on lines +75 to +86
- (+) 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.

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.

@AndresL230
AndresL230 merged commit 17353fc into mainJul 31, 2026
7 checks passed
@AndresL230
AndresL230 deleted the docs/0025-chunk-text-encryption-adr branch August 2, 2026 18:29
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, '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

docs(adr): 0025 — encrypt course_chunks.chunk_text (#484) - #503

Merged
AndresL230 merged 1 commit into
mainfrom
docs/0025-chunk-text-encryption-adr
Jul 31, 2026
Merged

docs(adr): 0025 — encrypt course_chunks.chunk_text (#484)#503
AndresL230 merged 1 commit into
mainfrom
docs/0025-chunk-text-encryption-adr

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Part of #484. Records the decision with the reasoning corrected.

The premise in the issue is wrong

pgvector similarity can't run over ciphertext

match_course_chunks (migration 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 SELECTed as payload — the ranking never reads it. Nothing queries it by content either (no ILIKE/LIKE/FTS anywhere in services/, routes/, scripts/).

So encryption doesn't block retrieval, and this is cheaper than the issue implies.

But the same fact makes it partial — and that's what the ADR exists to record

The embedding column cannot be encrypted, because pgvector must compute distance over it. An embedding is a lossy but real representation of its source text, and embedding-inversion recovers substantial content from vectors alone.

So encrypting chunk_text does not make the row opaque. Without that written down, the next person reads "chunk_text is encrypted" and concludes chunk content is confidential. It isn't. The ADR states this as a consequence rather than burying it.

What was decided

Encrypt uniformly — document and catalog chunks — with ids still computed on plaintext.

  • Uniform, because one invariant ("chunk_text is always ciphertext") is assertable by the existing ciphertext oracle, and because decrypt_if_present returns the raw value on failure — in a mixed table that makes a genuine decrypt failure indistinguishable from a legitimately-plaintext catalog row.
  • Ids on plaintext, because AES-GCM uses a random nonce: identical text encrypts differently every time, so ciphertext can never be a dedup key.

One trap found while verifying

scripts/dedupe_course_chunks.py:70 re-derives chunk ids from the storedchunk_text. Run against encrypted rows it would hash ciphertext and destroy content-addressing. It must decrypt first or be retired — its docstring calls it a one-time migration.

Scope

ADR only; no code. Implementation sites are listed at the bottom of the file. #483 is blocked on this decision, so landing the decision unblocks it.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Documentation
    • Added an architecture decision record documenting encryption for stored course content.
    • Clarified encryption coverage, retrieval compatibility, migration requirements, deduplication considerations, and remaining embedding exposure.

Records the decision Andres already made, with the reasoning corrected.
The issue's premise ("pgvector similarity can't run over ciphertext") is
wrong: match_course_chunks ranks on 1 - (embedding <=> query_embedding) and
only SELECTs chunk_text as payload, and nothing queries it by content. So
encryption doesn't block retrieval and the decision is cheaper than stated.
But the same fact makes it partial, which is the part worth recording: the
embedding can't be encrypted (pgvector computes distance over it) and is
partially invertible back to its source text. So this restores boundary
consistency with documents.extracted_text — it does not make chunks
confidential, and the ADR says so explicitly rather than letting a future
reader assume otherwise.
Decided uniform (document AND catalog chunks) so the invariant is assertable
by the existing ciphertext oracle, and because decrypt_if_present's
raw-value fallback would make a real decrypt failure indistinguishable from a
legitimately-plaintext catalog row in a mixed table.
Ids stay computed on plaintext: AES-GCM's random nonce means identical text
encrypts differently every time, so ciphertext can never be a dedup key.
Flagged that scripts/dedupe_course_chunks.py:70 re-derives ids from the
STORED text and would destroy content-addressing if run against encrypted
rows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 31, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Added ADR 0025. It defines encryption for course_chunks.chunk_text, plaintext embeddings for retrieval, content ID handling, migration requirements, decryption fallback, deduplication updates, and residual embedding exposure.

Changes

RAG Chunk Text Encryption

Layer / File(s)Summary
Encryption decision and migration rules
docs/decisions/0025-encrypt-rag-chunk-text.md
The ADR defines encryption and decryption behavior, plaintext-based IDs, retrieval compatibility, mixed-state backfills, deduplication changes, affected write and read paths, and threat-model limits.

Estimated code review effort: 1 (Trivial) | ~3 minutes

Possibly related issues

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the ADR and its main change: encrypting course_chunks.chunk_text.
Description check✅ PassedThe description clearly explains the decision, rationale, scope, related issue, and review considerations, but omits the template's explicit testing checklist.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch docs/0025-chunk-text-encryption-adr

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingc7b3c81Commit Preview URL

Branch Preview URL
Jul 31 2026, 07:44 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Prompt for all review comments with 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.
Inline comments:
In `@docs/decisions/0025-encrypt-rag-chunk-text.md`:
- Around line 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.
- Around line 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.
- 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.
- Around line 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.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 231e8094-2fdf-4b6f-a05f-6ffafc4f022f

📥 Commits

Reviewing files that changed from the base of the PR and between ac5e21e and c7b3c81.

📒 Files selected for processing (1)
  • docs/decisions/0025-encrypt-rag-chunk-text.md


## 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

Comment on lines +52 to +55
- `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.

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.

Comment on lines +58 to +61
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.

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.

Comment on lines +75 to +86
- (+) 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.

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.

@AndresL230
AndresL230 merged commit 17353fc into mainJul 31, 2026
7 checks passed
@AndresL230
AndresL230 deleted the docs/0025-chunk-text-encryption-adr branch August 2, 2026 18:29
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, '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

docs(adr): 0025 — encrypt course_chunks.chunk_text (#484) - #503

Merged
AndresL230 merged 1 commit into
mainfrom
docs/0025-chunk-text-encryption-adr
Jul 31, 2026
Merged

docs(adr): 0025 — encrypt course_chunks.chunk_text (#484)#503
AndresL230 merged 1 commit into
mainfrom
docs/0025-chunk-text-encryption-adr

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Part of #484. Records the decision with the reasoning corrected.

The premise in the issue is wrong

pgvector similarity can't run over ciphertext

match_course_chunks (migration 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 SELECTed as payload — the ranking never reads it. Nothing queries it by content either (no ILIKE/LIKE/FTS anywhere in services/, routes/, scripts/).

So encryption doesn't block retrieval, and this is cheaper than the issue implies.

But the same fact makes it partial — and that's what the ADR exists to record

The embedding column cannot be encrypted, because pgvector must compute distance over it. An embedding is a lossy but real representation of its source text, and embedding-inversion recovers substantial content from vectors alone.

So encrypting chunk_text does not make the row opaque. Without that written down, the next person reads "chunk_text is encrypted" and concludes chunk content is confidential. It isn't. The ADR states this as a consequence rather than burying it.

What was decided

Encrypt uniformly — document and catalog chunks — with ids still computed on plaintext.

  • Uniform, because one invariant ("chunk_text is always ciphertext") is assertable by the existing ciphertext oracle, and because decrypt_if_present returns the raw value on failure — in a mixed table that makes a genuine decrypt failure indistinguishable from a legitimately-plaintext catalog row.
  • Ids on plaintext, because AES-GCM uses a random nonce: identical text encrypts differently every time, so ciphertext can never be a dedup key.

One trap found while verifying

scripts/dedupe_course_chunks.py:70 re-derives chunk ids from the storedchunk_text. Run against encrypted rows it would hash ciphertext and destroy content-addressing. It must decrypt first or be retired — its docstring calls it a one-time migration.

Scope

ADR only; no code. Implementation sites are listed at the bottom of the file. #483 is blocked on this decision, so landing the decision unblocks it.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Documentation
    • Added an architecture decision record documenting encryption for stored course content.
    • Clarified encryption coverage, retrieval compatibility, migration requirements, deduplication considerations, and remaining embedding exposure.

Records the decision Andres already made, with the reasoning corrected.
The issue's premise ("pgvector similarity can't run over ciphertext") is
wrong: match_course_chunks ranks on 1 - (embedding <=> query_embedding) and
only SELECTs chunk_text as payload, and nothing queries it by content. So
encryption doesn't block retrieval and the decision is cheaper than stated.
But the same fact makes it partial, which is the part worth recording: the
embedding can't be encrypted (pgvector computes distance over it) and is
partially invertible back to its source text. So this restores boundary
consistency with documents.extracted_text — it does not make chunks
confidential, and the ADR says so explicitly rather than letting a future
reader assume otherwise.
Decided uniform (document AND catalog chunks) so the invariant is assertable
by the existing ciphertext oracle, and because decrypt_if_present's
raw-value fallback would make a real decrypt failure indistinguishable from a
legitimately-plaintext catalog row in a mixed table.
Ids stay computed on plaintext: AES-GCM's random nonce means identical text
encrypts differently every time, so ciphertext can never be a dedup key.
Flagged that scripts/dedupe_course_chunks.py:70 re-derives ids from the
STORED text and would destroy content-addressing if run against encrypted
rows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 31, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Added ADR 0025. It defines encryption for course_chunks.chunk_text, plaintext embeddings for retrieval, content ID handling, migration requirements, decryption fallback, deduplication updates, and residual embedding exposure.

Changes

RAG Chunk Text Encryption

Layer / File(s)Summary
Encryption decision and migration rules
docs/decisions/0025-encrypt-rag-chunk-text.md
The ADR defines encryption and decryption behavior, plaintext-based IDs, retrieval compatibility, mixed-state backfills, deduplication changes, affected write and read paths, and threat-model limits.

Estimated code review effort: 1 (Trivial) | ~3 minutes

Possibly related issues

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the ADR and its main change: encrypting course_chunks.chunk_text.
Description check✅ PassedThe description clearly explains the decision, rationale, scope, related issue, and review considerations, but omits the template's explicit testing checklist.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch docs/0025-chunk-text-encryption-adr

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingc7b3c81Commit Preview URL

Branch Preview URL
Jul 31 2026, 07:44 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Prompt for all review comments with 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.
Inline comments:
In `@docs/decisions/0025-encrypt-rag-chunk-text.md`:
- Around line 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.
- Around line 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.
- 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.
- Around line 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.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 231e8094-2fdf-4b6f-a05f-6ffafc4f022f

📥 Commits

Reviewing files that changed from the base of the PR and between ac5e21e and c7b3c81.

📒 Files selected for processing (1)
  • docs/decisions/0025-encrypt-rag-chunk-text.md


## 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

Comment on lines +52 to +55
- `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.

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.

Comment on lines +58 to +61
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.

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.

Comment on lines +75 to +86
- (+) 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.

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.

@AndresL230
AndresL230 merged commit 17353fc into mainJul 31, 2026
7 checks passed
@AndresL230
AndresL230 deleted the docs/0025-chunk-text-encryption-adr branch August 2, 2026 18:29
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, '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

docs(adr): 0025 — encrypt course_chunks.chunk_text (#484) - #503

Merged
AndresL230 merged 1 commit into
mainfrom
docs/0025-chunk-text-encryption-adr
Jul 31, 2026
Merged

docs(adr): 0025 — encrypt course_chunks.chunk_text (#484)#503
AndresL230 merged 1 commit into
mainfrom
docs/0025-chunk-text-encryption-adr

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Part of #484. Records the decision with the reasoning corrected.

The premise in the issue is wrong

pgvector similarity can't run over ciphertext

match_course_chunks (migration 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 SELECTed as payload — the ranking never reads it. Nothing queries it by content either (no ILIKE/LIKE/FTS anywhere in services/, routes/, scripts/).

So encryption doesn't block retrieval, and this is cheaper than the issue implies.

But the same fact makes it partial — and that's what the ADR exists to record

The embedding column cannot be encrypted, because pgvector must compute distance over it. An embedding is a lossy but real representation of its source text, and embedding-inversion recovers substantial content from vectors alone.

So encrypting chunk_text does not make the row opaque. Without that written down, the next person reads "chunk_text is encrypted" and concludes chunk content is confidential. It isn't. The ADR states this as a consequence rather than burying it.

What was decided

Encrypt uniformly — document and catalog chunks — with ids still computed on plaintext.

  • Uniform, because one invariant ("chunk_text is always ciphertext") is assertable by the existing ciphertext oracle, and because decrypt_if_present returns the raw value on failure — in a mixed table that makes a genuine decrypt failure indistinguishable from a legitimately-plaintext catalog row.
  • Ids on plaintext, because AES-GCM uses a random nonce: identical text encrypts differently every time, so ciphertext can never be a dedup key.

One trap found while verifying

scripts/dedupe_course_chunks.py:70 re-derives chunk ids from the storedchunk_text. Run against encrypted rows it would hash ciphertext and destroy content-addressing. It must decrypt first or be retired — its docstring calls it a one-time migration.

Scope

ADR only; no code. Implementation sites are listed at the bottom of the file. #483 is blocked on this decision, so landing the decision unblocks it.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Documentation
    • Added an architecture decision record documenting encryption for stored course content.
    • Clarified encryption coverage, retrieval compatibility, migration requirements, deduplication considerations, and remaining embedding exposure.

Records the decision Andres already made, with the reasoning corrected.
The issue's premise ("pgvector similarity can't run over ciphertext") is
wrong: match_course_chunks ranks on 1 - (embedding <=> query_embedding) and
only SELECTs chunk_text as payload, and nothing queries it by content. So
encryption doesn't block retrieval and the decision is cheaper than stated.
But the same fact makes it partial, which is the part worth recording: the
embedding can't be encrypted (pgvector computes distance over it) and is
partially invertible back to its source text. So this restores boundary
consistency with documents.extracted_text — it does not make chunks
confidential, and the ADR says so explicitly rather than letting a future
reader assume otherwise.
Decided uniform (document AND catalog chunks) so the invariant is assertable
by the existing ciphertext oracle, and because decrypt_if_present's
raw-value fallback would make a real decrypt failure indistinguishable from a
legitimately-plaintext catalog row in a mixed table.
Ids stay computed on plaintext: AES-GCM's random nonce means identical text
encrypts differently every time, so ciphertext can never be a dedup key.
Flagged that scripts/dedupe_course_chunks.py:70 re-derives ids from the
STORED text and would destroy content-addressing if run against encrypted
rows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 31, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Added ADR 0025. It defines encryption for course_chunks.chunk_text, plaintext embeddings for retrieval, content ID handling, migration requirements, decryption fallback, deduplication updates, and residual embedding exposure.

Changes

RAG Chunk Text Encryption

Layer / File(s)Summary
Encryption decision and migration rules
docs/decisions/0025-encrypt-rag-chunk-text.md
The ADR defines encryption and decryption behavior, plaintext-based IDs, retrieval compatibility, mixed-state backfills, deduplication changes, affected write and read paths, and threat-model limits.

Estimated code review effort: 1 (Trivial) | ~3 minutes

Possibly related issues

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the ADR and its main change: encrypting course_chunks.chunk_text.
Description check✅ PassedThe description clearly explains the decision, rationale, scope, related issue, and review considerations, but omits the template's explicit testing checklist.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch docs/0025-chunk-text-encryption-adr

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingc7b3c81Commit Preview URL

Branch Preview URL
Jul 31 2026, 07:44 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Prompt for all review comments with 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.
Inline comments:
In `@docs/decisions/0025-encrypt-rag-chunk-text.md`:
- Around line 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.
- Around line 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.
- 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.
- Around line 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.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 231e8094-2fdf-4b6f-a05f-6ffafc4f022f

📥 Commits

Reviewing files that changed from the base of the PR and between ac5e21e and c7b3c81.

📒 Files selected for processing (1)
  • docs/decisions/0025-encrypt-rag-chunk-text.md


## 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

Comment on lines +52 to +55
- `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.

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.

Comment on lines +58 to +61
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.

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.

Comment on lines +75 to +86
- (+) 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.

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.

@AndresL230
AndresL230 merged commit 17353fc into mainJul 31, 2026
7 checks passed
@AndresL230
AndresL230 deleted the docs/0025-chunk-text-encryption-adr branch August 2, 2026 18:29
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, '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

docs(adr): 0025 — encrypt course_chunks.chunk_text (#484) - #503

Merged
AndresL230 merged 1 commit into
mainfrom
docs/0025-chunk-text-encryption-adr
Jul 31, 2026
Merged

docs(adr): 0025 — encrypt course_chunks.chunk_text (#484)#503
AndresL230 merged 1 commit into
mainfrom
docs/0025-chunk-text-encryption-adr

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Part of #484. Records the decision with the reasoning corrected.

The premise in the issue is wrong

pgvector similarity can't run over ciphertext

match_course_chunks (migration 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 SELECTed as payload — the ranking never reads it. Nothing queries it by content either (no ILIKE/LIKE/FTS anywhere in services/, routes/, scripts/).

So encryption doesn't block retrieval, and this is cheaper than the issue implies.

But the same fact makes it partial — and that's what the ADR exists to record

The embedding column cannot be encrypted, because pgvector must compute distance over it. An embedding is a lossy but real representation of its source text, and embedding-inversion recovers substantial content from vectors alone.

So encrypting chunk_text does not make the row opaque. Without that written down, the next person reads "chunk_text is encrypted" and concludes chunk content is confidential. It isn't. The ADR states this as a consequence rather than burying it.

What was decided

Encrypt uniformly — document and catalog chunks — with ids still computed on plaintext.

  • Uniform, because one invariant ("chunk_text is always ciphertext") is assertable by the existing ciphertext oracle, and because decrypt_if_present returns the raw value on failure — in a mixed table that makes a genuine decrypt failure indistinguishable from a legitimately-plaintext catalog row.
  • Ids on plaintext, because AES-GCM uses a random nonce: identical text encrypts differently every time, so ciphertext can never be a dedup key.

One trap found while verifying

scripts/dedupe_course_chunks.py:70 re-derives chunk ids from the storedchunk_text. Run against encrypted rows it would hash ciphertext and destroy content-addressing. It must decrypt first or be retired — its docstring calls it a one-time migration.

Scope

ADR only; no code. Implementation sites are listed at the bottom of the file. #483 is blocked on this decision, so landing the decision unblocks it.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Documentation
    • Added an architecture decision record documenting encryption for stored course content.
    • Clarified encryption coverage, retrieval compatibility, migration requirements, deduplication considerations, and remaining embedding exposure.

Records the decision Andres already made, with the reasoning corrected.
The issue's premise ("pgvector similarity can't run over ciphertext") is
wrong: match_course_chunks ranks on 1 - (embedding <=> query_embedding) and
only SELECTs chunk_text as payload, and nothing queries it by content. So
encryption doesn't block retrieval and the decision is cheaper than stated.
But the same fact makes it partial, which is the part worth recording: the
embedding can't be encrypted (pgvector computes distance over it) and is
partially invertible back to its source text. So this restores boundary
consistency with documents.extracted_text — it does not make chunks
confidential, and the ADR says so explicitly rather than letting a future
reader assume otherwise.
Decided uniform (document AND catalog chunks) so the invariant is assertable
by the existing ciphertext oracle, and because decrypt_if_present's
raw-value fallback would make a real decrypt failure indistinguishable from a
legitimately-plaintext catalog row in a mixed table.
Ids stay computed on plaintext: AES-GCM's random nonce means identical text
encrypts differently every time, so ciphertext can never be a dedup key.
Flagged that scripts/dedupe_course_chunks.py:70 re-derives ids from the
STORED text and would destroy content-addressing if run against encrypted
rows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 31, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Added ADR 0025. It defines encryption for course_chunks.chunk_text, plaintext embeddings for retrieval, content ID handling, migration requirements, decryption fallback, deduplication updates, and residual embedding exposure.

Changes

RAG Chunk Text Encryption

Layer / File(s)Summary
Encryption decision and migration rules
docs/decisions/0025-encrypt-rag-chunk-text.md
The ADR defines encryption and decryption behavior, plaintext-based IDs, retrieval compatibility, mixed-state backfills, deduplication changes, affected write and read paths, and threat-model limits.

Estimated code review effort: 1 (Trivial) | ~3 minutes

Possibly related issues

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the ADR and its main change: encrypting course_chunks.chunk_text.
Description check✅ PassedThe description clearly explains the decision, rationale, scope, related issue, and review considerations, but omits the template's explicit testing checklist.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch docs/0025-chunk-text-encryption-adr

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingc7b3c81Commit Preview URL

Branch Preview URL
Jul 31 2026, 07:44 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Prompt for all review comments with 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.
Inline comments:
In `@docs/decisions/0025-encrypt-rag-chunk-text.md`:
- Around line 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.
- Around line 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.
- 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.
- Around line 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.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 231e8094-2fdf-4b6f-a05f-6ffafc4f022f

📥 Commits

Reviewing files that changed from the base of the PR and between ac5e21e and c7b3c81.

📒 Files selected for processing (1)
  • docs/decisions/0025-encrypt-rag-chunk-text.md


## 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

Comment on lines +52 to +55
- `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.

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.

Comment on lines +58 to +61
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.

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.

Comment on lines +75 to +86
- (+) 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.

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.

@AndresL230
AndresL230 merged commit 17353fc into mainJul 31, 2026
7 checks passed
@AndresL230
AndresL230 deleted the docs/0025-chunk-text-encryption-adr branch August 2, 2026 18:29
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, '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

docs(adr): 0025 — encrypt course_chunks.chunk_text (#484) - #503

Merged
AndresL230 merged 1 commit into
mainfrom
docs/0025-chunk-text-encryption-adr
Jul 31, 2026
Merged

docs(adr): 0025 — encrypt course_chunks.chunk_text (#484)#503
AndresL230 merged 1 commit into
mainfrom
docs/0025-chunk-text-encryption-adr

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Part of #484. Records the decision with the reasoning corrected.

The premise in the issue is wrong

pgvector similarity can't run over ciphertext

match_course_chunks (migration 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 SELECTed as payload — the ranking never reads it. Nothing queries it by content either (no ILIKE/LIKE/FTS anywhere in services/, routes/, scripts/).

So encryption doesn't block retrieval, and this is cheaper than the issue implies.

But the same fact makes it partial — and that's what the ADR exists to record

The embedding column cannot be encrypted, because pgvector must compute distance over it. An embedding is a lossy but real representation of its source text, and embedding-inversion recovers substantial content from vectors alone.

So encrypting chunk_text does not make the row opaque. Without that written down, the next person reads "chunk_text is encrypted" and concludes chunk content is confidential. It isn't. The ADR states this as a consequence rather than burying it.

What was decided

Encrypt uniformly — document and catalog chunks — with ids still computed on plaintext.

  • Uniform, because one invariant ("chunk_text is always ciphertext") is assertable by the existing ciphertext oracle, and because decrypt_if_present returns the raw value on failure — in a mixed table that makes a genuine decrypt failure indistinguishable from a legitimately-plaintext catalog row.
  • Ids on plaintext, because AES-GCM uses a random nonce: identical text encrypts differently every time, so ciphertext can never be a dedup key.

One trap found while verifying

scripts/dedupe_course_chunks.py:70 re-derives chunk ids from the storedchunk_text. Run against encrypted rows it would hash ciphertext and destroy content-addressing. It must decrypt first or be retired — its docstring calls it a one-time migration.

Scope

ADR only; no code. Implementation sites are listed at the bottom of the file. #483 is blocked on this decision, so landing the decision unblocks it.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Documentation
    • Added an architecture decision record documenting encryption for stored course content.
    • Clarified encryption coverage, retrieval compatibility, migration requirements, deduplication considerations, and remaining embedding exposure.

Records the decision Andres already made, with the reasoning corrected.
The issue's premise ("pgvector similarity can't run over ciphertext") is
wrong: match_course_chunks ranks on 1 - (embedding <=> query_embedding) and
only SELECTs chunk_text as payload, and nothing queries it by content. So
encryption doesn't block retrieval and the decision is cheaper than stated.
But the same fact makes it partial, which is the part worth recording: the
embedding can't be encrypted (pgvector computes distance over it) and is
partially invertible back to its source text. So this restores boundary
consistency with documents.extracted_text — it does not make chunks
confidential, and the ADR says so explicitly rather than letting a future
reader assume otherwise.
Decided uniform (document AND catalog chunks) so the invariant is assertable
by the existing ciphertext oracle, and because decrypt_if_present's
raw-value fallback would make a real decrypt failure indistinguishable from a
legitimately-plaintext catalog row in a mixed table.
Ids stay computed on plaintext: AES-GCM's random nonce means identical text
encrypts differently every time, so ciphertext can never be a dedup key.
Flagged that scripts/dedupe_course_chunks.py:70 re-derives ids from the
STORED text and would destroy content-addressing if run against encrypted
rows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 31, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Added ADR 0025. It defines encryption for course_chunks.chunk_text, plaintext embeddings for retrieval, content ID handling, migration requirements, decryption fallback, deduplication updates, and residual embedding exposure.

Changes

RAG Chunk Text Encryption

Layer / File(s)Summary
Encryption decision and migration rules
docs/decisions/0025-encrypt-rag-chunk-text.md
The ADR defines encryption and decryption behavior, plaintext-based IDs, retrieval compatibility, mixed-state backfills, deduplication changes, affected write and read paths, and threat-model limits.

Estimated code review effort: 1 (Trivial) | ~3 minutes

Possibly related issues

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly identifies the ADR and its main change: encrypting course_chunks.chunk_text.
Description check✅ PassedThe description clearly explains the decision, rationale, scope, related issue, and review considerations, but omits the template's explicit testing checklist.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch docs/0025-chunk-text-encryption-adr

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingc7b3c81Commit Preview URL

Branch Preview URL
Jul 31 2026, 07:44 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Prompt for all review comments with 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.
Inline comments:
In `@docs/decisions/0025-encrypt-rag-chunk-text.md`:
- Around line 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.
- Around line 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.
- 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.
- Around line 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.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 231e8094-2fdf-4b6f-a05f-6ffafc4f022f

📥 Commits

Reviewing files that changed from the base of the PR and between ac5e21e and c7b3c81.

📒 Files selected for processing (1)
  • docs/decisions/0025-encrypt-rag-chunk-text.md


## 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

Comment on lines +52 to +55
- `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.

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.

Comment on lines +58 to +61
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.

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.

Comment on lines +75 to +86
- (+) 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.

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.

@AndresL230
AndresL230 merged commit 17353fc into mainJul 31, 2026
7 checks passed
@AndresL230
AndresL230 deleted the docs/0025-chunk-text-encryption-adr branch August 2, 2026 18:29
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230