fix(gradescope): rewire onto the enrollment-keyed schema (#265) - #504

Merged
AndresL230 merged 3 commits into
mainfrom
fix/265-gradescope-enrollment-keyed
Jul 31, 2026
Merged

fix(gradescope): rewire onto the enrollment-keyed schema (#265)#504
AndresL230 merged 3 commits into
mainfrom
fix/265-gradescope-enrollment-keyed

Conversation

@AndresL230

Copy link
Copy Markdown
Collaborator

Part of #265.

Five drift points, not one

routes/gradescope.py was never migrated past the DB redesign. It named five things the schema does not have, so every link/sync/delete returned a PostgREST 400 in production — the same failure mode as #405:

#Code saysSchema says
1table user_coursesrenamed enrollments (0020)
2gradescope_course_links.user_id / .sapling_course_idenrollment_id (0027)
3assignments.user_id / .course_idenrollment-scoped (0021)
4table course_categoriesgradebook_categories (0021)
5writes source='gradescope'CHECK allows only {manual, syllabus}

Point 5 matters: even a perfect column fix would still have failed every insert, because the sync had no legal value to write. Migration 0042 widens the CHECK — a strict superset, so it is added VALIDATED with nothing to grandfather.

The UX question the issue asked to confirm

Answered enrollment-keyed. Grades land on enrollment-scoped assignments, so a link has to name the specific class instance — a retake gives two enrollments and an abstract-course link cannot say which term's assignments to write. The schema already said this in 0027; the code hadn't caught up.

Per the repo convention the HTTP boundary keeps the abstract course_id and resolves inward through the existing academics.enrollment_id_for, so the frontend contract is unchangedlist_links maps enrollment rows back out to course ids on the way through.

The reason this survived the lane built to catch it

RUN_INTEGRATION=1 pytest -m integration — the documented invocation — ran 1 passed, 27 skipped and exited 0. It reported green having tested nothing.

Collecting the whole tree imports tests/test_benchmark_quiz.py and tests/test_seed_quiz_fixture.py, which import scripts that call load_dotenv(".env.staging", override=True) at module import. That clobbers SUPABASE_URL to staging for the rest of the session, so _require_local_stack saw a non-local URL and skipped all 27. pytest tests/integration/ (directory form) ran fine on the same stack, which is presumably why it looked healthy.

The session fixture now re-asserts the local env at run time — not only at conftest import, which happens before those script imports — and raises rather than skipping when it is still wrong. That is the rule the same file already applied to SUPABASE_DB_URL ("raises loudly rather than skipping... a silent skip would read as safe"), now applied to SUPABASE_URL too.

Documented form: 1 passed / 27 skipped → 28 passed.

I include this here rather than splitting it because the four new integration tests are #265's regression guard, and a guard that silently skips is not one.

Verification

  • 4 new integration tests drive POST /link, GET /links, DELETE /link/{id} against real Postgres and read back with raw SQL — the assertions that would have caught the original drift. The mocked suite structurally cannot: it agrees with whatever column list the caller names.
  • Unit tests pin the shape (enrollment_id written, never the course id) so a regression back to the abstract id fails fast.
  • From-empty replay for 0042; hermetic 1534 passed; integration 28 passed; browser 37/37; oracles 0 findings.

Not covered:POST /sync/{id} drives a live Gradescope login and scrape, so its DB surface has no offline seam. Stated in the test file rather than left implicit.

🤖 Generated with Claude Code

AndresL230and others added 2 commits July 31, 2026 12:42
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>
routes/gradescope.py was never migrated past the DB redesign. It named FIVE
things the schema doesn't have, so every link/sync/delete returned a PostgREST
400 in production — the same failure mode as #405:
1. table user_courses -> renamed to enrollments in 0020
2. gradescope_course_links.user_id / .sapling_course_id
-> 0027 keys the table on enrollment_id
3. assignments.user_id / .course_id
-> 0021 made assignments enrollment-scoped
4. table course_categories -> renamed gradebook_categories in 0021
5. assignments.source CHECK allowed only {manual,syllabus}, but the sync
writes source='gradescope' — so even with the columns fixed there was no
legal value to write. Migration 0042 widens it (strict superset, so the
constraint is added VALIDATED).
The issue asked to confirm the intended UX. Enrollment-keyed is right: grades
land on enrollment-scoped assignments, so a link has to name the specific class
instance — a retake gives two enrollments and an abstract-course link cannot
say which term's assignments to write. The schema already said this; the code
hadn't caught up.
Per the repo convention the HTTP boundary keeps the abstract course_id and
resolves inward via academics.enrollment_id_for, so the FRONTEND CONTRACT IS
UNCHANGED — list_links maps enrollment rows back out to course ids.
Also fixes the reason this drift survived the lane built to catch it.
`RUN_INTEGRATION=1 pytest -m integration` — the documented invocation —
silently ran NOTHING and exited 0. Collecting the whole tree imports
tests/test_benchmark_quiz.py and tests/test_seed_quiz_fixture.py, which import
scripts that call load_dotenv(".env.staging", override=True) at module import;
that clobbered SUPABASE_URL for the session, so _require_local_stack skipped
all 27 integration tests. The session fixture now re-asserts the local env at
run time and RAISES rather than skipping when it is still wrong — the rule the
file already applied to SUPABASE_DB_URL, now applied to SUPABASE_URL too.
Documented form went from "1 passed, 27 skipped" to "28 passed".
Verified: 4 new integration tests exercise the real column lists against real
Postgres (the mocked suite structurally cannot — it agrees with whatever the
caller asserts, which is why this shipped); from-empty replay for 0042;
hermetic 1534 passed; browser 37/37; oracles clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 31, 2026

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-stagingb5555d5Commit Preview URL

Branch Preview URL
Jul 31 2026, 08:28 PM

@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

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:16 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c7c085ab-bd00-4371-89d8-794663ae61db

📥 Commits

Reviewing files that changed from the base of the PR and between 17353fc and b5555d5.

📒 Files selected for processing (6)
  • backend/db/migrations/0042_assignments_source_gradescope.sql
  • backend/routes/gradescope.py
  • backend/tests/integration/conftest.py
  • backend/tests/integration/test_gradescope_links.py
  • backend/tests/test_gradescope.py
  • docs/decisions/0025-encrypt-rag-chunk-text.md

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.

…ment
Code review found a real bug in the first cut — and it was the exact retake
case I used to JUSTIFY enrollment-keying, which is why it's worth spelling out.
enrollment_id_for re-derives ONE enrollment from the current term on every
call. That's right for CREATING a link and wrong for finding one that already
exists: the seeded user holds CS101 in both fall-2025 and spring-2026, so a
link attached to the enrollment the heuristic doesn't pick was invisible to
every write path. Re-linking created a SECOND row; DELETE removed nothing
while still answering ok:true; sync 400'd "no link" for a course GET /links
was happily listing. The GET/write asymmetry was the tell — list_links
enumerates every enrollment, the writers only ever touched one.
_all_enrollments_for() now spans every enrollment in the course. upsert_link
deletes across all of them before inserting (so re-linking after a rollover
leaves exactly one), remove_link deletes across all of them, and sync_course
keys the whole run on the LINK ROW's enrollment_id rather than a fresh guess —
grades belong to the class instance the link was made against.
Two integration tests cover it, written against the enrollment the resolver
does NOT prefer so the fixture is the hostile case. Both verified red against
the pre-fix code (2 failed / 4 passed) and green after.
Also from review:
- list_links drops rows whose course can't be resolved instead of answering
sapling_course_id: null — a null there reads as a real link the UI can't act
on.
- 0042 switches to NOT VALID. The old comment justified skipping it on the
data ("nothing to grandfather"), which answered the wrong question: the
reason that matters is that a plain ADD CONSTRAINT takes ACCESS EXCLUSIVE on
assignments for the whole validation scan. NOT VALID still enforces every
new and updated row, which is all a widened set needs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 3 issues, all fixed in b5555d5.

  1. The write routes re-derived the enrollment instead of following the existing link — and it was the exact retake case this PR's rationale uses to justify enrollment-keying (bug due to enrollment_id_for resolving one current-term enrollment per call, while list_links enumerates all of them).

The seeded user holds CS101 in both fall-2025 and spring-2026. A link attached to the enrollment the heuristic doesn't pick was invisible to every write path: re-linking created a second row, DELETE removed nothing while still answering ok: true, and sync 400'd "No Gradescope course is linked" for a course GET /links was still listing. The GET/write asymmetry was the tell.

https://github.com/SaplingLearn/Sapling/blob/2c249c2/backend/routes/gradescope.py#L406-L419

Fixed with _all_enrollments_for(): upsert_link deletes across every enrollment in the course before inserting (so a rollover leaves exactly one link), remove_link deletes across all of them, and sync_course keys the run on the link row'senrollment_id rather than a fresh guess — the grades belong to the class instance the link was made against.

Two integration tests cover it, written against the enrollment the resolver does not prefer so the fixture is the hostile case. Verified red against the pre-fix code (2 failed / 4 passed) and green after.

  1. list_links answered sapling_course_id: null for an enrollment whose course couldn't be resolved. Now dropped — a null there reads as a real link the UI has no id to act on.

  2. Migration 0042's rationale answered the wrong question. It justified skipping NOT VALID on the data ("nothing to grandfather"), but the reason that matters is lock behaviour: a plain ADD CONSTRAINT ... CHECK takes ACCESS EXCLUSIVE on assignments for the full validation scan. Switched to NOT VALID, which still enforces every new and updated row — all a widened set needs.

Checked and cleared: the drift audit confirms exactly five points, no sixth (the remaining user_id filters target gradescope_credentials, which is genuinely still user-keyed per 0027); the in.(...) PostgREST syntax is correct; the conftest raise doesn't break any CI workflow (none set RUN_INTEGRATION without a stack).

Noted, not fixed: the conftest fix repairs os.environ but cannot un-freeze a module that already captured a staging value at import. Safe today only because db.connection is imported before those scripts during collection, and nothing asserts that ordering. The general hazard — arbitrary scripts calling load_dotenv(..., override=True) at import time — deserves its own fix in those scripts.

🤖 Generated with Claude Code

@AndresL230
AndresL230 merged commit 37328d6 into mainJul 31, 2026
7 checks passed
AndresL230 added a commit that referenced this pull request Aug 1, 2026
* ci: apply pending migrations to staging on merge to main
Nothing applied them. Verified all four places it could have happened and none
did: the backend image's CMD is a bare uvicorn, there is no Procfile or release
step, main.py's lifespan does not migrate, and the Supabase GitHub integration
reads `supabase/migrations/` — the CLI convention — which this repo does not
have (only config.toml and snippets live under supabase/, and schema_paths is
empty). That is why its check reports "skipping" on every PR: it is connected
but has nothing it recognises. Migrations here are raw DDL under
backend/db/migrations/ applied by db/migrate.py against its own ledger.
So a merge shipped code whose schema had not moved, and someone had to remember
to run it. #504 is live proof: it merged code that writes source='gradescope'
while the CHECK still rejects that value until 0042 is applied.
STAGING ONLY, deliberately. main deploys staging; prod is a separate
`production` branch promotion, and auto-applying irreversible DDL to prod on
merge is a different risk decision. This runner has no down migrations.
Two safety properties, both exercised against the real local database rather
than assumed:
- No secret set -> notice + skip, so adding this file changes nothing until
STAGING_SUPABASE_DB_URL exists.
- Preflight refuses to apply on a drifted ledger: a missing schema_migrations
table (the #317 shape) or any recorded-but-absent filename fails the job
with the offending name, instead of pushing more DDL on top of a history
the repo and database already disagree about.
Tested three ways: healthy (45 on disk / 45 recorded / 0 pending, exit 0),
injected drift (exit 1, names the ghost row), and absent ledger (exit 1).
The first draft queried a `version` column; the ledger's column is `filename`,
which only the real-database test caught.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* ci: restrict the migrate job to main; pin psycopg range
Code review found a real hole. workflow_dispatch lets you pick ANY branch
containing the workflow file, so a migration could be applied to shared staging
straight from an unmerged branch, bypassing the push-to-main gate the whole
design assumes.
The bypass is not the worst part. The filename lands in schema_migrations, so
if the file is then edited before merging — easy, since it was only "tested" —
the merge never re-applies it, and staging silently diverges from the canonical
file with NO pending/orphan signal, because the recorded filename still
matches. That is precisely the immutability rule CLAUDE.md states, violated
without a trace. Job-level `if: github.ref == 'refs/heads/main'` closes it.
Also pins psycopg to >=3.2,<4 to match backend/requirements.txt, so the runner
can't silently drift onto a major the app has never run against.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@AndresL230
AndresL230 deleted the fix/265-gradescope-enrollment-keyed branch August 2, 2026 18:30
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)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
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;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} 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

fix(gradescope): rewire onto the enrollment-keyed schema (#265) - #504

Merged
AndresL230 merged 3 commits into
mainfrom
fix/265-gradescope-enrollment-keyed
Jul 31, 2026
Merged

fix(gradescope): rewire onto the enrollment-keyed schema (#265)#504
AndresL230 merged 3 commits into
mainfrom
fix/265-gradescope-enrollment-keyed

Conversation

@AndresL230

Copy link
Copy Markdown
Collaborator

Part of #265.

Five drift points, not one

routes/gradescope.py was never migrated past the DB redesign. It named five things the schema does not have, so every link/sync/delete returned a PostgREST 400 in production — the same failure mode as #405:

#Code saysSchema says
1table user_coursesrenamed enrollments (0020)
2gradescope_course_links.user_id / .sapling_course_idenrollment_id (0027)
3assignments.user_id / .course_idenrollment-scoped (0021)
4table course_categoriesgradebook_categories (0021)
5writes source='gradescope'CHECK allows only {manual, syllabus}

Point 5 matters: even a perfect column fix would still have failed every insert, because the sync had no legal value to write. Migration 0042 widens the CHECK — a strict superset, so it is added VALIDATED with nothing to grandfather.

The UX question the issue asked to confirm

Answered enrollment-keyed. Grades land on enrollment-scoped assignments, so a link has to name the specific class instance — a retake gives two enrollments and an abstract-course link cannot say which term's assignments to write. The schema already said this in 0027; the code hadn't caught up.

Per the repo convention the HTTP boundary keeps the abstract course_id and resolves inward through the existing academics.enrollment_id_for, so the frontend contract is unchangedlist_links maps enrollment rows back out to course ids on the way through.

The reason this survived the lane built to catch it

RUN_INTEGRATION=1 pytest -m integration — the documented invocation — ran 1 passed, 27 skipped and exited 0. It reported green having tested nothing.

Collecting the whole tree imports tests/test_benchmark_quiz.py and tests/test_seed_quiz_fixture.py, which import scripts that call load_dotenv(".env.staging", override=True) at module import. That clobbers SUPABASE_URL to staging for the rest of the session, so _require_local_stack saw a non-local URL and skipped all 27. pytest tests/integration/ (directory form) ran fine on the same stack, which is presumably why it looked healthy.

The session fixture now re-asserts the local env at run time — not only at conftest import, which happens before those script imports — and raises rather than skipping when it is still wrong. That is the rule the same file already applied to SUPABASE_DB_URL ("raises loudly rather than skipping... a silent skip would read as safe"), now applied to SUPABASE_URL too.

Documented form: 1 passed / 27 skipped → 28 passed.

I include this here rather than splitting it because the four new integration tests are #265's regression guard, and a guard that silently skips is not one.

Verification

  • 4 new integration tests drive POST /link, GET /links, DELETE /link/{id} against real Postgres and read back with raw SQL — the assertions that would have caught the original drift. The mocked suite structurally cannot: it agrees with whatever column list the caller names.
  • Unit tests pin the shape (enrollment_id written, never the course id) so a regression back to the abstract id fails fast.
  • From-empty replay for 0042; hermetic 1534 passed; integration 28 passed; browser 37/37; oracles 0 findings.

Not covered:POST /sync/{id} drives a live Gradescope login and scrape, so its DB surface has no offline seam. Stated in the test file rather than left implicit.

🤖 Generated with Claude Code

AndresL230and others added 2 commits July 31, 2026 12:42
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>
routes/gradescope.py was never migrated past the DB redesign. It named FIVE
things the schema doesn't have, so every link/sync/delete returned a PostgREST
400 in production — the same failure mode as #405:
1. table user_courses -> renamed to enrollments in 0020
2. gradescope_course_links.user_id / .sapling_course_id
-> 0027 keys the table on enrollment_id
3. assignments.user_id / .course_id
-> 0021 made assignments enrollment-scoped
4. table course_categories -> renamed gradebook_categories in 0021
5. assignments.source CHECK allowed only {manual,syllabus}, but the sync
writes source='gradescope' — so even with the columns fixed there was no
legal value to write. Migration 0042 widens it (strict superset, so the
constraint is added VALIDATED).
The issue asked to confirm the intended UX. Enrollment-keyed is right: grades
land on enrollment-scoped assignments, so a link has to name the specific class
instance — a retake gives two enrollments and an abstract-course link cannot
say which term's assignments to write. The schema already said this; the code
hadn't caught up.
Per the repo convention the HTTP boundary keeps the abstract course_id and
resolves inward via academics.enrollment_id_for, so the FRONTEND CONTRACT IS
UNCHANGED — list_links maps enrollment rows back out to course ids.
Also fixes the reason this drift survived the lane built to catch it.
`RUN_INTEGRATION=1 pytest -m integration` — the documented invocation —
silently ran NOTHING and exited 0. Collecting the whole tree imports
tests/test_benchmark_quiz.py and tests/test_seed_quiz_fixture.py, which import
scripts that call load_dotenv(".env.staging", override=True) at module import;
that clobbered SUPABASE_URL for the session, so _require_local_stack skipped
all 27 integration tests. The session fixture now re-asserts the local env at
run time and RAISES rather than skipping when it is still wrong — the rule the
file already applied to SUPABASE_DB_URL, now applied to SUPABASE_URL too.
Documented form went from "1 passed, 27 skipped" to "28 passed".
Verified: 4 new integration tests exercise the real column lists against real
Postgres (the mocked suite structurally cannot — it agrees with whatever the
caller asserts, which is why this shipped); from-empty replay for 0042;
hermetic 1534 passed; browser 37/37; oracles clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 31, 2026

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-stagingb5555d5Commit Preview URL

Branch Preview URL
Jul 31 2026, 08:28 PM

@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

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:16 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c7c085ab-bd00-4371-89d8-794663ae61db

📥 Commits

Reviewing files that changed from the base of the PR and between 17353fc and b5555d5.

📒 Files selected for processing (6)
  • backend/db/migrations/0042_assignments_source_gradescope.sql
  • backend/routes/gradescope.py
  • backend/tests/integration/conftest.py
  • backend/tests/integration/test_gradescope_links.py
  • backend/tests/test_gradescope.py
  • docs/decisions/0025-encrypt-rag-chunk-text.md

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.

…ment
Code review found a real bug in the first cut — and it was the exact retake
case I used to JUSTIFY enrollment-keying, which is why it's worth spelling out.
enrollment_id_for re-derives ONE enrollment from the current term on every
call. That's right for CREATING a link and wrong for finding one that already
exists: the seeded user holds CS101 in both fall-2025 and spring-2026, so a
link attached to the enrollment the heuristic doesn't pick was invisible to
every write path. Re-linking created a SECOND row; DELETE removed nothing
while still answering ok:true; sync 400'd "no link" for a course GET /links
was happily listing. The GET/write asymmetry was the tell — list_links
enumerates every enrollment, the writers only ever touched one.
_all_enrollments_for() now spans every enrollment in the course. upsert_link
deletes across all of them before inserting (so re-linking after a rollover
leaves exactly one), remove_link deletes across all of them, and sync_course
keys the whole run on the LINK ROW's enrollment_id rather than a fresh guess —
grades belong to the class instance the link was made against.
Two integration tests cover it, written against the enrollment the resolver
does NOT prefer so the fixture is the hostile case. Both verified red against
the pre-fix code (2 failed / 4 passed) and green after.
Also from review:
- list_links drops rows whose course can't be resolved instead of answering
sapling_course_id: null — a null there reads as a real link the UI can't act
on.
- 0042 switches to NOT VALID. The old comment justified skipping it on the
data ("nothing to grandfather"), which answered the wrong question: the
reason that matters is that a plain ADD CONSTRAINT takes ACCESS EXCLUSIVE on
assignments for the whole validation scan. NOT VALID still enforces every
new and updated row, which is all a widened set needs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 3 issues, all fixed in b5555d5.

  1. The write routes re-derived the enrollment instead of following the existing link — and it was the exact retake case this PR's rationale uses to justify enrollment-keying (bug due to enrollment_id_for resolving one current-term enrollment per call, while list_links enumerates all of them).

The seeded user holds CS101 in both fall-2025 and spring-2026. A link attached to the enrollment the heuristic doesn't pick was invisible to every write path: re-linking created a second row, DELETE removed nothing while still answering ok: true, and sync 400'd "No Gradescope course is linked" for a course GET /links was still listing. The GET/write asymmetry was the tell.

https://github.com/SaplingLearn/Sapling/blob/2c249c2/backend/routes/gradescope.py#L406-L419

Fixed with _all_enrollments_for(): upsert_link deletes across every enrollment in the course before inserting (so a rollover leaves exactly one link), remove_link deletes across all of them, and sync_course keys the run on the link row'senrollment_id rather than a fresh guess — the grades belong to the class instance the link was made against.

Two integration tests cover it, written against the enrollment the resolver does not prefer so the fixture is the hostile case. Verified red against the pre-fix code (2 failed / 4 passed) and green after.

  1. list_links answered sapling_course_id: null for an enrollment whose course couldn't be resolved. Now dropped — a null there reads as a real link the UI has no id to act on.

  2. Migration 0042's rationale answered the wrong question. It justified skipping NOT VALID on the data ("nothing to grandfather"), but the reason that matters is lock behaviour: a plain ADD CONSTRAINT ... CHECK takes ACCESS EXCLUSIVE on assignments for the full validation scan. Switched to NOT VALID, which still enforces every new and updated row — all a widened set needs.

Checked and cleared: the drift audit confirms exactly five points, no sixth (the remaining user_id filters target gradescope_credentials, which is genuinely still user-keyed per 0027); the in.(...) PostgREST syntax is correct; the conftest raise doesn't break any CI workflow (none set RUN_INTEGRATION without a stack).

Noted, not fixed: the conftest fix repairs os.environ but cannot un-freeze a module that already captured a staging value at import. Safe today only because db.connection is imported before those scripts during collection, and nothing asserts that ordering. The general hazard — arbitrary scripts calling load_dotenv(..., override=True) at import time — deserves its own fix in those scripts.

🤖 Generated with Claude Code

@AndresL230
AndresL230 merged commit 37328d6 into mainJul 31, 2026
7 checks passed
AndresL230 added a commit that referenced this pull request Aug 1, 2026
* ci: apply pending migrations to staging on merge to main
Nothing applied them. Verified all four places it could have happened and none
did: the backend image's CMD is a bare uvicorn, there is no Procfile or release
step, main.py's lifespan does not migrate, and the Supabase GitHub integration
reads `supabase/migrations/` — the CLI convention — which this repo does not
have (only config.toml and snippets live under supabase/, and schema_paths is
empty). That is why its check reports "skipping" on every PR: it is connected
but has nothing it recognises. Migrations here are raw DDL under
backend/db/migrations/ applied by db/migrate.py against its own ledger.
So a merge shipped code whose schema had not moved, and someone had to remember
to run it. #504 is live proof: it merged code that writes source='gradescope'
while the CHECK still rejects that value until 0042 is applied.
STAGING ONLY, deliberately. main deploys staging; prod is a separate
`production` branch promotion, and auto-applying irreversible DDL to prod on
merge is a different risk decision. This runner has no down migrations.
Two safety properties, both exercised against the real local database rather
than assumed:
- No secret set -> notice + skip, so adding this file changes nothing until
STAGING_SUPABASE_DB_URL exists.
- Preflight refuses to apply on a drifted ledger: a missing schema_migrations
table (the #317 shape) or any recorded-but-absent filename fails the job
with the offending name, instead of pushing more DDL on top of a history
the repo and database already disagree about.
Tested three ways: healthy (45 on disk / 45 recorded / 0 pending, exit 0),
injected drift (exit 1, names the ghost row), and absent ledger (exit 1).
The first draft queried a `version` column; the ledger's column is `filename`,
which only the real-database test caught.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* ci: restrict the migrate job to main; pin psycopg range
Code review found a real hole. workflow_dispatch lets you pick ANY branch
containing the workflow file, so a migration could be applied to shared staging
straight from an unmerged branch, bypassing the push-to-main gate the whole
design assumes.
The bypass is not the worst part. The filename lands in schema_migrations, so
if the file is then edited before merging — easy, since it was only "tested" —
the merge never re-applies it, and staging silently diverges from the canonical
file with NO pending/orphan signal, because the recorded filename still
matches. That is precisely the immutability rule CLAUDE.md states, violated
without a trace. Job-level `if: github.ref == 'refs/heads/main'` closes it.
Also pins psycopg to >=3.2,<4 to match backend/requirements.txt, so the runner
can't silently drift onto a major the app has never run against.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@AndresL230
AndresL230 deleted the fix/265-gradescope-enrollment-keyed branch August 2, 2026 18:30
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)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(gradescope): rewire onto the enrollment-keyed schema (#265) - #504

Merged
AndresL230 merged 3 commits into
mainfrom
fix/265-gradescope-enrollment-keyed
Jul 31, 2026
Merged

fix(gradescope): rewire onto the enrollment-keyed schema (#265)#504
AndresL230 merged 3 commits into
mainfrom
fix/265-gradescope-enrollment-keyed

Conversation

@AndresL230

Copy link
Copy Markdown
Collaborator

Part of #265.

Five drift points, not one

routes/gradescope.py was never migrated past the DB redesign. It named five things the schema does not have, so every link/sync/delete returned a PostgREST 400 in production — the same failure mode as #405:

#Code saysSchema says
1table user_coursesrenamed enrollments (0020)
2gradescope_course_links.user_id / .sapling_course_idenrollment_id (0027)
3assignments.user_id / .course_idenrollment-scoped (0021)
4table course_categoriesgradebook_categories (0021)
5writes source='gradescope'CHECK allows only {manual, syllabus}

Point 5 matters: even a perfect column fix would still have failed every insert, because the sync had no legal value to write. Migration 0042 widens the CHECK — a strict superset, so it is added VALIDATED with nothing to grandfather.

The UX question the issue asked to confirm

Answered enrollment-keyed. Grades land on enrollment-scoped assignments, so a link has to name the specific class instance — a retake gives two enrollments and an abstract-course link cannot say which term's assignments to write. The schema already said this in 0027; the code hadn't caught up.

Per the repo convention the HTTP boundary keeps the abstract course_id and resolves inward through the existing academics.enrollment_id_for, so the frontend contract is unchangedlist_links maps enrollment rows back out to course ids on the way through.

The reason this survived the lane built to catch it

RUN_INTEGRATION=1 pytest -m integration — the documented invocation — ran 1 passed, 27 skipped and exited 0. It reported green having tested nothing.

Collecting the whole tree imports tests/test_benchmark_quiz.py and tests/test_seed_quiz_fixture.py, which import scripts that call load_dotenv(".env.staging", override=True) at module import. That clobbers SUPABASE_URL to staging for the rest of the session, so _require_local_stack saw a non-local URL and skipped all 27. pytest tests/integration/ (directory form) ran fine on the same stack, which is presumably why it looked healthy.

The session fixture now re-asserts the local env at run time — not only at conftest import, which happens before those script imports — and raises rather than skipping when it is still wrong. That is the rule the same file already applied to SUPABASE_DB_URL ("raises loudly rather than skipping... a silent skip would read as safe"), now applied to SUPABASE_URL too.

Documented form: 1 passed / 27 skipped → 28 passed.

I include this here rather than splitting it because the four new integration tests are #265's regression guard, and a guard that silently skips is not one.

Verification

  • 4 new integration tests drive POST /link, GET /links, DELETE /link/{id} against real Postgres and read back with raw SQL — the assertions that would have caught the original drift. The mocked suite structurally cannot: it agrees with whatever column list the caller names.
  • Unit tests pin the shape (enrollment_id written, never the course id) so a regression back to the abstract id fails fast.
  • From-empty replay for 0042; hermetic 1534 passed; integration 28 passed; browser 37/37; oracles 0 findings.

Not covered:POST /sync/{id} drives a live Gradescope login and scrape, so its DB surface has no offline seam. Stated in the test file rather than left implicit.

🤖 Generated with Claude Code

AndresL230and others added 2 commits July 31, 2026 12:42
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>
routes/gradescope.py was never migrated past the DB redesign. It named FIVE
things the schema doesn't have, so every link/sync/delete returned a PostgREST
400 in production — the same failure mode as #405:
1. table user_courses -> renamed to enrollments in 0020
2. gradescope_course_links.user_id / .sapling_course_id
-> 0027 keys the table on enrollment_id
3. assignments.user_id / .course_id
-> 0021 made assignments enrollment-scoped
4. table course_categories -> renamed gradebook_categories in 0021
5. assignments.source CHECK allowed only {manual,syllabus}, but the sync
writes source='gradescope' — so even with the columns fixed there was no
legal value to write. Migration 0042 widens it (strict superset, so the
constraint is added VALIDATED).
The issue asked to confirm the intended UX. Enrollment-keyed is right: grades
land on enrollment-scoped assignments, so a link has to name the specific class
instance — a retake gives two enrollments and an abstract-course link cannot
say which term's assignments to write. The schema already said this; the code
hadn't caught up.
Per the repo convention the HTTP boundary keeps the abstract course_id and
resolves inward via academics.enrollment_id_for, so the FRONTEND CONTRACT IS
UNCHANGED — list_links maps enrollment rows back out to course ids.
Also fixes the reason this drift survived the lane built to catch it.
`RUN_INTEGRATION=1 pytest -m integration` — the documented invocation —
silently ran NOTHING and exited 0. Collecting the whole tree imports
tests/test_benchmark_quiz.py and tests/test_seed_quiz_fixture.py, which import
scripts that call load_dotenv(".env.staging", override=True) at module import;
that clobbered SUPABASE_URL for the session, so _require_local_stack skipped
all 27 integration tests. The session fixture now re-asserts the local env at
run time and RAISES rather than skipping when it is still wrong — the rule the
file already applied to SUPABASE_DB_URL, now applied to SUPABASE_URL too.
Documented form went from "1 passed, 27 skipped" to "28 passed".
Verified: 4 new integration tests exercise the real column lists against real
Postgres (the mocked suite structurally cannot — it agrees with whatever the
caller asserts, which is why this shipped); from-empty replay for 0042;
hermetic 1534 passed; browser 37/37; oracles clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 31, 2026

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-stagingb5555d5Commit Preview URL

Branch Preview URL
Jul 31 2026, 08:28 PM

@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

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:16 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c7c085ab-bd00-4371-89d8-794663ae61db

📥 Commits

Reviewing files that changed from the base of the PR and between 17353fc and b5555d5.

📒 Files selected for processing (6)
  • backend/db/migrations/0042_assignments_source_gradescope.sql
  • backend/routes/gradescope.py
  • backend/tests/integration/conftest.py
  • backend/tests/integration/test_gradescope_links.py
  • backend/tests/test_gradescope.py
  • docs/decisions/0025-encrypt-rag-chunk-text.md

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.

…ment
Code review found a real bug in the first cut — and it was the exact retake
case I used to JUSTIFY enrollment-keying, which is why it's worth spelling out.
enrollment_id_for re-derives ONE enrollment from the current term on every
call. That's right for CREATING a link and wrong for finding one that already
exists: the seeded user holds CS101 in both fall-2025 and spring-2026, so a
link attached to the enrollment the heuristic doesn't pick was invisible to
every write path. Re-linking created a SECOND row; DELETE removed nothing
while still answering ok:true; sync 400'd "no link" for a course GET /links
was happily listing. The GET/write asymmetry was the tell — list_links
enumerates every enrollment, the writers only ever touched one.
_all_enrollments_for() now spans every enrollment in the course. upsert_link
deletes across all of them before inserting (so re-linking after a rollover
leaves exactly one), remove_link deletes across all of them, and sync_course
keys the whole run on the LINK ROW's enrollment_id rather than a fresh guess —
grades belong to the class instance the link was made against.
Two integration tests cover it, written against the enrollment the resolver
does NOT prefer so the fixture is the hostile case. Both verified red against
the pre-fix code (2 failed / 4 passed) and green after.
Also from review:
- list_links drops rows whose course can't be resolved instead of answering
sapling_course_id: null — a null there reads as a real link the UI can't act
on.
- 0042 switches to NOT VALID. The old comment justified skipping it on the
data ("nothing to grandfather"), which answered the wrong question: the
reason that matters is that a plain ADD CONSTRAINT takes ACCESS EXCLUSIVE on
assignments for the whole validation scan. NOT VALID still enforces every
new and updated row, which is all a widened set needs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 3 issues, all fixed in b5555d5.

  1. The write routes re-derived the enrollment instead of following the existing link — and it was the exact retake case this PR's rationale uses to justify enrollment-keying (bug due to enrollment_id_for resolving one current-term enrollment per call, while list_links enumerates all of them).

The seeded user holds CS101 in both fall-2025 and spring-2026. A link attached to the enrollment the heuristic doesn't pick was invisible to every write path: re-linking created a second row, DELETE removed nothing while still answering ok: true, and sync 400'd "No Gradescope course is linked" for a course GET /links was still listing. The GET/write asymmetry was the tell.

https://github.com/SaplingLearn/Sapling/blob/2c249c2/backend/routes/gradescope.py#L406-L419

Fixed with _all_enrollments_for(): upsert_link deletes across every enrollment in the course before inserting (so a rollover leaves exactly one link), remove_link deletes across all of them, and sync_course keys the run on the link row'senrollment_id rather than a fresh guess — the grades belong to the class instance the link was made against.

Two integration tests cover it, written against the enrollment the resolver does not prefer so the fixture is the hostile case. Verified red against the pre-fix code (2 failed / 4 passed) and green after.

  1. list_links answered sapling_course_id: null for an enrollment whose course couldn't be resolved. Now dropped — a null there reads as a real link the UI has no id to act on.

  2. Migration 0042's rationale answered the wrong question. It justified skipping NOT VALID on the data ("nothing to grandfather"), but the reason that matters is lock behaviour: a plain ADD CONSTRAINT ... CHECK takes ACCESS EXCLUSIVE on assignments for the full validation scan. Switched to NOT VALID, which still enforces every new and updated row — all a widened set needs.

Checked and cleared: the drift audit confirms exactly five points, no sixth (the remaining user_id filters target gradescope_credentials, which is genuinely still user-keyed per 0027); the in.(...) PostgREST syntax is correct; the conftest raise doesn't break any CI workflow (none set RUN_INTEGRATION without a stack).

Noted, not fixed: the conftest fix repairs os.environ but cannot un-freeze a module that already captured a staging value at import. Safe today only because db.connection is imported before those scripts during collection, and nothing asserts that ordering. The general hazard — arbitrary scripts calling load_dotenv(..., override=True) at import time — deserves its own fix in those scripts.

🤖 Generated with Claude Code

@AndresL230
AndresL230 merged commit 37328d6 into mainJul 31, 2026
7 checks passed
AndresL230 added a commit that referenced this pull request Aug 1, 2026
* ci: apply pending migrations to staging on merge to main
Nothing applied them. Verified all four places it could have happened and none
did: the backend image's CMD is a bare uvicorn, there is no Procfile or release
step, main.py's lifespan does not migrate, and the Supabase GitHub integration
reads `supabase/migrations/` — the CLI convention — which this repo does not
have (only config.toml and snippets live under supabase/, and schema_paths is
empty). That is why its check reports "skipping" on every PR: it is connected
but has nothing it recognises. Migrations here are raw DDL under
backend/db/migrations/ applied by db/migrate.py against its own ledger.
So a merge shipped code whose schema had not moved, and someone had to remember
to run it. #504 is live proof: it merged code that writes source='gradescope'
while the CHECK still rejects that value until 0042 is applied.
STAGING ONLY, deliberately. main deploys staging; prod is a separate
`production` branch promotion, and auto-applying irreversible DDL to prod on
merge is a different risk decision. This runner has no down migrations.
Two safety properties, both exercised against the real local database rather
than assumed:
- No secret set -> notice + skip, so adding this file changes nothing until
STAGING_SUPABASE_DB_URL exists.
- Preflight refuses to apply on a drifted ledger: a missing schema_migrations
table (the #317 shape) or any recorded-but-absent filename fails the job
with the offending name, instead of pushing more DDL on top of a history
the repo and database already disagree about.
Tested three ways: healthy (45 on disk / 45 recorded / 0 pending, exit 0),
injected drift (exit 1, names the ghost row), and absent ledger (exit 1).
The first draft queried a `version` column; the ledger's column is `filename`,
which only the real-database test caught.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* ci: restrict the migrate job to main; pin psycopg range
Code review found a real hole. workflow_dispatch lets you pick ANY branch
containing the workflow file, so a migration could be applied to shared staging
straight from an unmerged branch, bypassing the push-to-main gate the whole
design assumes.
The bypass is not the worst part. The filename lands in schema_migrations, so
if the file is then edited before merging — easy, since it was only "tested" —
the merge never re-applies it, and staging silently diverges from the canonical
file with NO pending/orphan signal, because the recorded filename still
matches. That is precisely the immutability rule CLAUDE.md states, violated
without a trace. Job-level `if: github.ref == 'refs/heads/main'` closes it.
Also pins psycopg to >=3.2,<4 to match backend/requirements.txt, so the runner
can't silently drift onto a major the app has never run against.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@AndresL230
AndresL230 deleted the fix/265-gradescope-enrollment-keyed branch August 2, 2026 18:30
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)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(gradescope): rewire onto the enrollment-keyed schema (#265) - #504

Merged
AndresL230 merged 3 commits into
mainfrom
fix/265-gradescope-enrollment-keyed
Jul 31, 2026
Merged

fix(gradescope): rewire onto the enrollment-keyed schema (#265)#504
AndresL230 merged 3 commits into
mainfrom
fix/265-gradescope-enrollment-keyed

Conversation

@AndresL230

Copy link
Copy Markdown
Collaborator

Part of #265.

Five drift points, not one

routes/gradescope.py was never migrated past the DB redesign. It named five things the schema does not have, so every link/sync/delete returned a PostgREST 400 in production — the same failure mode as #405:

#Code saysSchema says
1table user_coursesrenamed enrollments (0020)
2gradescope_course_links.user_id / .sapling_course_idenrollment_id (0027)
3assignments.user_id / .course_idenrollment-scoped (0021)
4table course_categoriesgradebook_categories (0021)
5writes source='gradescope'CHECK allows only {manual, syllabus}

Point 5 matters: even a perfect column fix would still have failed every insert, because the sync had no legal value to write. Migration 0042 widens the CHECK — a strict superset, so it is added VALIDATED with nothing to grandfather.

The UX question the issue asked to confirm

Answered enrollment-keyed. Grades land on enrollment-scoped assignments, so a link has to name the specific class instance — a retake gives two enrollments and an abstract-course link cannot say which term's assignments to write. The schema already said this in 0027; the code hadn't caught up.

Per the repo convention the HTTP boundary keeps the abstract course_id and resolves inward through the existing academics.enrollment_id_for, so the frontend contract is unchangedlist_links maps enrollment rows back out to course ids on the way through.

The reason this survived the lane built to catch it

RUN_INTEGRATION=1 pytest -m integration — the documented invocation — ran 1 passed, 27 skipped and exited 0. It reported green having tested nothing.

Collecting the whole tree imports tests/test_benchmark_quiz.py and tests/test_seed_quiz_fixture.py, which import scripts that call load_dotenv(".env.staging", override=True) at module import. That clobbers SUPABASE_URL to staging for the rest of the session, so _require_local_stack saw a non-local URL and skipped all 27. pytest tests/integration/ (directory form) ran fine on the same stack, which is presumably why it looked healthy.

The session fixture now re-asserts the local env at run time — not only at conftest import, which happens before those script imports — and raises rather than skipping when it is still wrong. That is the rule the same file already applied to SUPABASE_DB_URL ("raises loudly rather than skipping... a silent skip would read as safe"), now applied to SUPABASE_URL too.

Documented form: 1 passed / 27 skipped → 28 passed.

I include this here rather than splitting it because the four new integration tests are #265's regression guard, and a guard that silently skips is not one.

Verification

  • 4 new integration tests drive POST /link, GET /links, DELETE /link/{id} against real Postgres and read back with raw SQL — the assertions that would have caught the original drift. The mocked suite structurally cannot: it agrees with whatever column list the caller names.
  • Unit tests pin the shape (enrollment_id written, never the course id) so a regression back to the abstract id fails fast.
  • From-empty replay for 0042; hermetic 1534 passed; integration 28 passed; browser 37/37; oracles 0 findings.

Not covered:POST /sync/{id} drives a live Gradescope login and scrape, so its DB surface has no offline seam. Stated in the test file rather than left implicit.

🤖 Generated with Claude Code

AndresL230and others added 2 commits July 31, 2026 12:42
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>
routes/gradescope.py was never migrated past the DB redesign. It named FIVE
things the schema doesn't have, so every link/sync/delete returned a PostgREST
400 in production — the same failure mode as #405:
1. table user_courses -> renamed to enrollments in 0020
2. gradescope_course_links.user_id / .sapling_course_id
-> 0027 keys the table on enrollment_id
3. assignments.user_id / .course_id
-> 0021 made assignments enrollment-scoped
4. table course_categories -> renamed gradebook_categories in 0021
5. assignments.source CHECK allowed only {manual,syllabus}, but the sync
writes source='gradescope' — so even with the columns fixed there was no
legal value to write. Migration 0042 widens it (strict superset, so the
constraint is added VALIDATED).
The issue asked to confirm the intended UX. Enrollment-keyed is right: grades
land on enrollment-scoped assignments, so a link has to name the specific class
instance — a retake gives two enrollments and an abstract-course link cannot
say which term's assignments to write. The schema already said this; the code
hadn't caught up.
Per the repo convention the HTTP boundary keeps the abstract course_id and
resolves inward via academics.enrollment_id_for, so the FRONTEND CONTRACT IS
UNCHANGED — list_links maps enrollment rows back out to course ids.
Also fixes the reason this drift survived the lane built to catch it.
`RUN_INTEGRATION=1 pytest -m integration` — the documented invocation —
silently ran NOTHING and exited 0. Collecting the whole tree imports
tests/test_benchmark_quiz.py and tests/test_seed_quiz_fixture.py, which import
scripts that call load_dotenv(".env.staging", override=True) at module import;
that clobbered SUPABASE_URL for the session, so _require_local_stack skipped
all 27 integration tests. The session fixture now re-asserts the local env at
run time and RAISES rather than skipping when it is still wrong — the rule the
file already applied to SUPABASE_DB_URL, now applied to SUPABASE_URL too.
Documented form went from "1 passed, 27 skipped" to "28 passed".
Verified: 4 new integration tests exercise the real column lists against real
Postgres (the mocked suite structurally cannot — it agrees with whatever the
caller asserts, which is why this shipped); from-empty replay for 0042;
hermetic 1534 passed; browser 37/37; oracles clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 31, 2026

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-stagingb5555d5Commit Preview URL

Branch Preview URL
Jul 31 2026, 08:28 PM

@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

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:16 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c7c085ab-bd00-4371-89d8-794663ae61db

📥 Commits

Reviewing files that changed from the base of the PR and between 17353fc and b5555d5.

📒 Files selected for processing (6)
  • backend/db/migrations/0042_assignments_source_gradescope.sql
  • backend/routes/gradescope.py
  • backend/tests/integration/conftest.py
  • backend/tests/integration/test_gradescope_links.py
  • backend/tests/test_gradescope.py
  • docs/decisions/0025-encrypt-rag-chunk-text.md

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.

…ment
Code review found a real bug in the first cut — and it was the exact retake
case I used to JUSTIFY enrollment-keying, which is why it's worth spelling out.
enrollment_id_for re-derives ONE enrollment from the current term on every
call. That's right for CREATING a link and wrong for finding one that already
exists: the seeded user holds CS101 in both fall-2025 and spring-2026, so a
link attached to the enrollment the heuristic doesn't pick was invisible to
every write path. Re-linking created a SECOND row; DELETE removed nothing
while still answering ok:true; sync 400'd "no link" for a course GET /links
was happily listing. The GET/write asymmetry was the tell — list_links
enumerates every enrollment, the writers only ever touched one.
_all_enrollments_for() now spans every enrollment in the course. upsert_link
deletes across all of them before inserting (so re-linking after a rollover
leaves exactly one), remove_link deletes across all of them, and sync_course
keys the whole run on the LINK ROW's enrollment_id rather than a fresh guess —
grades belong to the class instance the link was made against.
Two integration tests cover it, written against the enrollment the resolver
does NOT prefer so the fixture is the hostile case. Both verified red against
the pre-fix code (2 failed / 4 passed) and green after.
Also from review:
- list_links drops rows whose course can't be resolved instead of answering
sapling_course_id: null — a null there reads as a real link the UI can't act
on.
- 0042 switches to NOT VALID. The old comment justified skipping it on the
data ("nothing to grandfather"), which answered the wrong question: the
reason that matters is that a plain ADD CONSTRAINT takes ACCESS EXCLUSIVE on
assignments for the whole validation scan. NOT VALID still enforces every
new and updated row, which is all a widened set needs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 3 issues, all fixed in b5555d5.

  1. The write routes re-derived the enrollment instead of following the existing link — and it was the exact retake case this PR's rationale uses to justify enrollment-keying (bug due to enrollment_id_for resolving one current-term enrollment per call, while list_links enumerates all of them).

The seeded user holds CS101 in both fall-2025 and spring-2026. A link attached to the enrollment the heuristic doesn't pick was invisible to every write path: re-linking created a second row, DELETE removed nothing while still answering ok: true, and sync 400'd "No Gradescope course is linked" for a course GET /links was still listing. The GET/write asymmetry was the tell.

https://github.com/SaplingLearn/Sapling/blob/2c249c2/backend/routes/gradescope.py#L406-L419

Fixed with _all_enrollments_for(): upsert_link deletes across every enrollment in the course before inserting (so a rollover leaves exactly one link), remove_link deletes across all of them, and sync_course keys the run on the link row'senrollment_id rather than a fresh guess — the grades belong to the class instance the link was made against.

Two integration tests cover it, written against the enrollment the resolver does not prefer so the fixture is the hostile case. Verified red against the pre-fix code (2 failed / 4 passed) and green after.

  1. list_links answered sapling_course_id: null for an enrollment whose course couldn't be resolved. Now dropped — a null there reads as a real link the UI has no id to act on.

  2. Migration 0042's rationale answered the wrong question. It justified skipping NOT VALID on the data ("nothing to grandfather"), but the reason that matters is lock behaviour: a plain ADD CONSTRAINT ... CHECK takes ACCESS EXCLUSIVE on assignments for the full validation scan. Switched to NOT VALID, which still enforces every new and updated row — all a widened set needs.

Checked and cleared: the drift audit confirms exactly five points, no sixth (the remaining user_id filters target gradescope_credentials, which is genuinely still user-keyed per 0027); the in.(...) PostgREST syntax is correct; the conftest raise doesn't break any CI workflow (none set RUN_INTEGRATION without a stack).

Noted, not fixed: the conftest fix repairs os.environ but cannot un-freeze a module that already captured a staging value at import. Safe today only because db.connection is imported before those scripts during collection, and nothing asserts that ordering. The general hazard — arbitrary scripts calling load_dotenv(..., override=True) at import time — deserves its own fix in those scripts.

🤖 Generated with Claude Code

@AndresL230
AndresL230 merged commit 37328d6 into mainJul 31, 2026
7 checks passed
AndresL230 added a commit that referenced this pull request Aug 1, 2026
* ci: apply pending migrations to staging on merge to main
Nothing applied them. Verified all four places it could have happened and none
did: the backend image's CMD is a bare uvicorn, there is no Procfile or release
step, main.py's lifespan does not migrate, and the Supabase GitHub integration
reads `supabase/migrations/` — the CLI convention — which this repo does not
have (only config.toml and snippets live under supabase/, and schema_paths is
empty). That is why its check reports "skipping" on every PR: it is connected
but has nothing it recognises. Migrations here are raw DDL under
backend/db/migrations/ applied by db/migrate.py against its own ledger.
So a merge shipped code whose schema had not moved, and someone had to remember
to run it. #504 is live proof: it merged code that writes source='gradescope'
while the CHECK still rejects that value until 0042 is applied.
STAGING ONLY, deliberately. main deploys staging; prod is a separate
`production` branch promotion, and auto-applying irreversible DDL to prod on
merge is a different risk decision. This runner has no down migrations.
Two safety properties, both exercised against the real local database rather
than assumed:
- No secret set -> notice + skip, so adding this file changes nothing until
STAGING_SUPABASE_DB_URL exists.
- Preflight refuses to apply on a drifted ledger: a missing schema_migrations
table (the #317 shape) or any recorded-but-absent filename fails the job
with the offending name, instead of pushing more DDL on top of a history
the repo and database already disagree about.
Tested three ways: healthy (45 on disk / 45 recorded / 0 pending, exit 0),
injected drift (exit 1, names the ghost row), and absent ledger (exit 1).
The first draft queried a `version` column; the ledger's column is `filename`,
which only the real-database test caught.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* ci: restrict the migrate job to main; pin psycopg range
Code review found a real hole. workflow_dispatch lets you pick ANY branch
containing the workflow file, so a migration could be applied to shared staging
straight from an unmerged branch, bypassing the push-to-main gate the whole
design assumes.
The bypass is not the worst part. The filename lands in schema_migrations, so
if the file is then edited before merging — easy, since it was only "tested" —
the merge never re-applies it, and staging silently diverges from the canonical
file with NO pending/orphan signal, because the recorded filename still
matches. That is precisely the immutability rule CLAUDE.md states, violated
without a trace. Job-level `if: github.ref == 'refs/heads/main'` closes it.
Also pins psycopg to >=3.2,<4 to match backend/requirements.txt, so the runner
can't silently drift onto a major the app has never run against.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@AndresL230
AndresL230 deleted the fix/265-gradescope-enrollment-keyed branch August 2, 2026 18:30
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)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } 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

fix(gradescope): rewire onto the enrollment-keyed schema (#265) - #504

Merged
AndresL230 merged 3 commits into
mainfrom
fix/265-gradescope-enrollment-keyed
Jul 31, 2026
Merged

fix(gradescope): rewire onto the enrollment-keyed schema (#265)#504
AndresL230 merged 3 commits into
mainfrom
fix/265-gradescope-enrollment-keyed

Conversation

@AndresL230

Copy link
Copy Markdown
Collaborator

Part of #265.

Five drift points, not one

routes/gradescope.py was never migrated past the DB redesign. It named five things the schema does not have, so every link/sync/delete returned a PostgREST 400 in production — the same failure mode as #405:

#Code saysSchema says
1table user_coursesrenamed enrollments (0020)
2gradescope_course_links.user_id / .sapling_course_idenrollment_id (0027)
3assignments.user_id / .course_idenrollment-scoped (0021)
4table course_categoriesgradebook_categories (0021)
5writes source='gradescope'CHECK allows only {manual, syllabus}

Point 5 matters: even a perfect column fix would still have failed every insert, because the sync had no legal value to write. Migration 0042 widens the CHECK — a strict superset, so it is added VALIDATED with nothing to grandfather.

The UX question the issue asked to confirm

Answered enrollment-keyed. Grades land on enrollment-scoped assignments, so a link has to name the specific class instance — a retake gives two enrollments and an abstract-course link cannot say which term's assignments to write. The schema already said this in 0027; the code hadn't caught up.

Per the repo convention the HTTP boundary keeps the abstract course_id and resolves inward through the existing academics.enrollment_id_for, so the frontend contract is unchangedlist_links maps enrollment rows back out to course ids on the way through.

The reason this survived the lane built to catch it

RUN_INTEGRATION=1 pytest -m integration — the documented invocation — ran 1 passed, 27 skipped and exited 0. It reported green having tested nothing.

Collecting the whole tree imports tests/test_benchmark_quiz.py and tests/test_seed_quiz_fixture.py, which import scripts that call load_dotenv(".env.staging", override=True) at module import. That clobbers SUPABASE_URL to staging for the rest of the session, so _require_local_stack saw a non-local URL and skipped all 27. pytest tests/integration/ (directory form) ran fine on the same stack, which is presumably why it looked healthy.

The session fixture now re-asserts the local env at run time — not only at conftest import, which happens before those script imports — and raises rather than skipping when it is still wrong. That is the rule the same file already applied to SUPABASE_DB_URL ("raises loudly rather than skipping... a silent skip would read as safe"), now applied to SUPABASE_URL too.

Documented form: 1 passed / 27 skipped → 28 passed.

I include this here rather than splitting it because the four new integration tests are #265's regression guard, and a guard that silently skips is not one.

Verification

  • 4 new integration tests drive POST /link, GET /links, DELETE /link/{id} against real Postgres and read back with raw SQL — the assertions that would have caught the original drift. The mocked suite structurally cannot: it agrees with whatever column list the caller names.
  • Unit tests pin the shape (enrollment_id written, never the course id) so a regression back to the abstract id fails fast.
  • From-empty replay for 0042; hermetic 1534 passed; integration 28 passed; browser 37/37; oracles 0 findings.

Not covered:POST /sync/{id} drives a live Gradescope login and scrape, so its DB surface has no offline seam. Stated in the test file rather than left implicit.

🤖 Generated with Claude Code

AndresL230and others added 2 commits July 31, 2026 12:42
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>
routes/gradescope.py was never migrated past the DB redesign. It named FIVE
things the schema doesn't have, so every link/sync/delete returned a PostgREST
400 in production — the same failure mode as #405:
1. table user_courses -> renamed to enrollments in 0020
2. gradescope_course_links.user_id / .sapling_course_id
-> 0027 keys the table on enrollment_id
3. assignments.user_id / .course_id
-> 0021 made assignments enrollment-scoped
4. table course_categories -> renamed gradebook_categories in 0021
5. assignments.source CHECK allowed only {manual,syllabus}, but the sync
writes source='gradescope' — so even with the columns fixed there was no
legal value to write. Migration 0042 widens it (strict superset, so the
constraint is added VALIDATED).
The issue asked to confirm the intended UX. Enrollment-keyed is right: grades
land on enrollment-scoped assignments, so a link has to name the specific class
instance — a retake gives two enrollments and an abstract-course link cannot
say which term's assignments to write. The schema already said this; the code
hadn't caught up.
Per the repo convention the HTTP boundary keeps the abstract course_id and
resolves inward via academics.enrollment_id_for, so the FRONTEND CONTRACT IS
UNCHANGED — list_links maps enrollment rows back out to course ids.
Also fixes the reason this drift survived the lane built to catch it.
`RUN_INTEGRATION=1 pytest -m integration` — the documented invocation —
silently ran NOTHING and exited 0. Collecting the whole tree imports
tests/test_benchmark_quiz.py and tests/test_seed_quiz_fixture.py, which import
scripts that call load_dotenv(".env.staging", override=True) at module import;
that clobbered SUPABASE_URL for the session, so _require_local_stack skipped
all 27 integration tests. The session fixture now re-asserts the local env at
run time and RAISES rather than skipping when it is still wrong — the rule the
file already applied to SUPABASE_DB_URL, now applied to SUPABASE_URL too.
Documented form went from "1 passed, 27 skipped" to "28 passed".
Verified: 4 new integration tests exercise the real column lists against real
Postgres (the mocked suite structurally cannot — it agrees with whatever the
caller asserts, which is why this shipped); from-empty replay for 0042;
hermetic 1534 passed; browser 37/37; oracles clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 31, 2026

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-stagingb5555d5Commit Preview URL

Branch Preview URL
Jul 31 2026, 08:28 PM

@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

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:16 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c7c085ab-bd00-4371-89d8-794663ae61db

📥 Commits

Reviewing files that changed from the base of the PR and between 17353fc and b5555d5.

📒 Files selected for processing (6)
  • backend/db/migrations/0042_assignments_source_gradescope.sql
  • backend/routes/gradescope.py
  • backend/tests/integration/conftest.py
  • backend/tests/integration/test_gradescope_links.py
  • backend/tests/test_gradescope.py
  • docs/decisions/0025-encrypt-rag-chunk-text.md

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.

…ment
Code review found a real bug in the first cut — and it was the exact retake
case I used to JUSTIFY enrollment-keying, which is why it's worth spelling out.
enrollment_id_for re-derives ONE enrollment from the current term on every
call. That's right for CREATING a link and wrong for finding one that already
exists: the seeded user holds CS101 in both fall-2025 and spring-2026, so a
link attached to the enrollment the heuristic doesn't pick was invisible to
every write path. Re-linking created a SECOND row; DELETE removed nothing
while still answering ok:true; sync 400'd "no link" for a course GET /links
was happily listing. The GET/write asymmetry was the tell — list_links
enumerates every enrollment, the writers only ever touched one.
_all_enrollments_for() now spans every enrollment in the course. upsert_link
deletes across all of them before inserting (so re-linking after a rollover
leaves exactly one), remove_link deletes across all of them, and sync_course
keys the whole run on the LINK ROW's enrollment_id rather than a fresh guess —
grades belong to the class instance the link was made against.
Two integration tests cover it, written against the enrollment the resolver
does NOT prefer so the fixture is the hostile case. Both verified red against
the pre-fix code (2 failed / 4 passed) and green after.
Also from review:
- list_links drops rows whose course can't be resolved instead of answering
sapling_course_id: null — a null there reads as a real link the UI can't act
on.
- 0042 switches to NOT VALID. The old comment justified skipping it on the
data ("nothing to grandfather"), which answered the wrong question: the
reason that matters is that a plain ADD CONSTRAINT takes ACCESS EXCLUSIVE on
assignments for the whole validation scan. NOT VALID still enforces every
new and updated row, which is all a widened set needs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 3 issues, all fixed in b5555d5.

  1. The write routes re-derived the enrollment instead of following the existing link — and it was the exact retake case this PR's rationale uses to justify enrollment-keying (bug due to enrollment_id_for resolving one current-term enrollment per call, while list_links enumerates all of them).

The seeded user holds CS101 in both fall-2025 and spring-2026. A link attached to the enrollment the heuristic doesn't pick was invisible to every write path: re-linking created a second row, DELETE removed nothing while still answering ok: true, and sync 400'd "No Gradescope course is linked" for a course GET /links was still listing. The GET/write asymmetry was the tell.

https://github.com/SaplingLearn/Sapling/blob/2c249c2/backend/routes/gradescope.py#L406-L419

Fixed with _all_enrollments_for(): upsert_link deletes across every enrollment in the course before inserting (so a rollover leaves exactly one link), remove_link deletes across all of them, and sync_course keys the run on the link row'senrollment_id rather than a fresh guess — the grades belong to the class instance the link was made against.

Two integration tests cover it, written against the enrollment the resolver does not prefer so the fixture is the hostile case. Verified red against the pre-fix code (2 failed / 4 passed) and green after.

  1. list_links answered sapling_course_id: null for an enrollment whose course couldn't be resolved. Now dropped — a null there reads as a real link the UI has no id to act on.

  2. Migration 0042's rationale answered the wrong question. It justified skipping NOT VALID on the data ("nothing to grandfather"), but the reason that matters is lock behaviour: a plain ADD CONSTRAINT ... CHECK takes ACCESS EXCLUSIVE on assignments for the full validation scan. Switched to NOT VALID, which still enforces every new and updated row — all a widened set needs.

Checked and cleared: the drift audit confirms exactly five points, no sixth (the remaining user_id filters target gradescope_credentials, which is genuinely still user-keyed per 0027); the in.(...) PostgREST syntax is correct; the conftest raise doesn't break any CI workflow (none set RUN_INTEGRATION without a stack).

Noted, not fixed: the conftest fix repairs os.environ but cannot un-freeze a module that already captured a staging value at import. Safe today only because db.connection is imported before those scripts during collection, and nothing asserts that ordering. The general hazard — arbitrary scripts calling load_dotenv(..., override=True) at import time — deserves its own fix in those scripts.

🤖 Generated with Claude Code

@AndresL230
AndresL230 merged commit 37328d6 into mainJul 31, 2026
7 checks passed
AndresL230 added a commit that referenced this pull request Aug 1, 2026
* ci: apply pending migrations to staging on merge to main
Nothing applied them. Verified all four places it could have happened and none
did: the backend image's CMD is a bare uvicorn, there is no Procfile or release
step, main.py's lifespan does not migrate, and the Supabase GitHub integration
reads `supabase/migrations/` — the CLI convention — which this repo does not
have (only config.toml and snippets live under supabase/, and schema_paths is
empty). That is why its check reports "skipping" on every PR: it is connected
but has nothing it recognises. Migrations here are raw DDL under
backend/db/migrations/ applied by db/migrate.py against its own ledger.
So a merge shipped code whose schema had not moved, and someone had to remember
to run it. #504 is live proof: it merged code that writes source='gradescope'
while the CHECK still rejects that value until 0042 is applied.
STAGING ONLY, deliberately. main deploys staging; prod is a separate
`production` branch promotion, and auto-applying irreversible DDL to prod on
merge is a different risk decision. This runner has no down migrations.
Two safety properties, both exercised against the real local database rather
than assumed:
- No secret set -> notice + skip, so adding this file changes nothing until
STAGING_SUPABASE_DB_URL exists.
- Preflight refuses to apply on a drifted ledger: a missing schema_migrations
table (the #317 shape) or any recorded-but-absent filename fails the job
with the offending name, instead of pushing more DDL on top of a history
the repo and database already disagree about.
Tested three ways: healthy (45 on disk / 45 recorded / 0 pending, exit 0),
injected drift (exit 1, names the ghost row), and absent ledger (exit 1).
The first draft queried a `version` column; the ledger's column is `filename`,
which only the real-database test caught.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* ci: restrict the migrate job to main; pin psycopg range
Code review found a real hole. workflow_dispatch lets you pick ANY branch
containing the workflow file, so a migration could be applied to shared staging
straight from an unmerged branch, bypassing the push-to-main gate the whole
design assumes.
The bypass is not the worst part. The filename lands in schema_migrations, so
if the file is then edited before merging — easy, since it was only "tested" —
the merge never re-applies it, and staging silently diverges from the canonical
file with NO pending/orphan signal, because the recorded filename still
matches. That is precisely the immutability rule CLAUDE.md states, violated
without a trace. Job-level `if: github.ref == 'refs/heads/main'` closes it.
Also pins psycopg to >=3.2,<4 to match backend/requirements.txt, so the runner
can't silently drift onto a major the app has never run against.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@AndresL230
AndresL230 deleted the fix/265-gradescope-enrollment-keyed branch August 2, 2026 18:30
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)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(gradescope): rewire onto the enrollment-keyed schema (#265) - #504

Merged
AndresL230 merged 3 commits into
mainfrom
fix/265-gradescope-enrollment-keyed
Jul 31, 2026
Merged

fix(gradescope): rewire onto the enrollment-keyed schema (#265)#504
AndresL230 merged 3 commits into
mainfrom
fix/265-gradescope-enrollment-keyed

Conversation

@AndresL230

Copy link
Copy Markdown
Collaborator

Part of #265.

Five drift points, not one

routes/gradescope.py was never migrated past the DB redesign. It named five things the schema does not have, so every link/sync/delete returned a PostgREST 400 in production — the same failure mode as #405:

#Code saysSchema says
1table user_coursesrenamed enrollments (0020)
2gradescope_course_links.user_id / .sapling_course_idenrollment_id (0027)
3assignments.user_id / .course_idenrollment-scoped (0021)
4table course_categoriesgradebook_categories (0021)
5writes source='gradescope'CHECK allows only {manual, syllabus}

Point 5 matters: even a perfect column fix would still have failed every insert, because the sync had no legal value to write. Migration 0042 widens the CHECK — a strict superset, so it is added VALIDATED with nothing to grandfather.

The UX question the issue asked to confirm

Answered enrollment-keyed. Grades land on enrollment-scoped assignments, so a link has to name the specific class instance — a retake gives two enrollments and an abstract-course link cannot say which term's assignments to write. The schema already said this in 0027; the code hadn't caught up.

Per the repo convention the HTTP boundary keeps the abstract course_id and resolves inward through the existing academics.enrollment_id_for, so the frontend contract is unchangedlist_links maps enrollment rows back out to course ids on the way through.

The reason this survived the lane built to catch it

RUN_INTEGRATION=1 pytest -m integration — the documented invocation — ran 1 passed, 27 skipped and exited 0. It reported green having tested nothing.

Collecting the whole tree imports tests/test_benchmark_quiz.py and tests/test_seed_quiz_fixture.py, which import scripts that call load_dotenv(".env.staging", override=True) at module import. That clobbers SUPABASE_URL to staging for the rest of the session, so _require_local_stack saw a non-local URL and skipped all 27. pytest tests/integration/ (directory form) ran fine on the same stack, which is presumably why it looked healthy.

The session fixture now re-asserts the local env at run time — not only at conftest import, which happens before those script imports — and raises rather than skipping when it is still wrong. That is the rule the same file already applied to SUPABASE_DB_URL ("raises loudly rather than skipping... a silent skip would read as safe"), now applied to SUPABASE_URL too.

Documented form: 1 passed / 27 skipped → 28 passed.

I include this here rather than splitting it because the four new integration tests are #265's regression guard, and a guard that silently skips is not one.

Verification

  • 4 new integration tests drive POST /link, GET /links, DELETE /link/{id} against real Postgres and read back with raw SQL — the assertions that would have caught the original drift. The mocked suite structurally cannot: it agrees with whatever column list the caller names.
  • Unit tests pin the shape (enrollment_id written, never the course id) so a regression back to the abstract id fails fast.
  • From-empty replay for 0042; hermetic 1534 passed; integration 28 passed; browser 37/37; oracles 0 findings.

Not covered:POST /sync/{id} drives a live Gradescope login and scrape, so its DB surface has no offline seam. Stated in the test file rather than left implicit.

🤖 Generated with Claude Code

AndresL230and others added 2 commits July 31, 2026 12:42
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>
routes/gradescope.py was never migrated past the DB redesign. It named FIVE
things the schema doesn't have, so every link/sync/delete returned a PostgREST
400 in production — the same failure mode as #405:
1. table user_courses -> renamed to enrollments in 0020
2. gradescope_course_links.user_id / .sapling_course_id
-> 0027 keys the table on enrollment_id
3. assignments.user_id / .course_id
-> 0021 made assignments enrollment-scoped
4. table course_categories -> renamed gradebook_categories in 0021
5. assignments.source CHECK allowed only {manual,syllabus}, but the sync
writes source='gradescope' — so even with the columns fixed there was no
legal value to write. Migration 0042 widens it (strict superset, so the
constraint is added VALIDATED).
The issue asked to confirm the intended UX. Enrollment-keyed is right: grades
land on enrollment-scoped assignments, so a link has to name the specific class
instance — a retake gives two enrollments and an abstract-course link cannot
say which term's assignments to write. The schema already said this; the code
hadn't caught up.
Per the repo convention the HTTP boundary keeps the abstract course_id and
resolves inward via academics.enrollment_id_for, so the FRONTEND CONTRACT IS
UNCHANGED — list_links maps enrollment rows back out to course ids.
Also fixes the reason this drift survived the lane built to catch it.
`RUN_INTEGRATION=1 pytest -m integration` — the documented invocation —
silently ran NOTHING and exited 0. Collecting the whole tree imports
tests/test_benchmark_quiz.py and tests/test_seed_quiz_fixture.py, which import
scripts that call load_dotenv(".env.staging", override=True) at module import;
that clobbered SUPABASE_URL for the session, so _require_local_stack skipped
all 27 integration tests. The session fixture now re-asserts the local env at
run time and RAISES rather than skipping when it is still wrong — the rule the
file already applied to SUPABASE_DB_URL, now applied to SUPABASE_URL too.
Documented form went from "1 passed, 27 skipped" to "28 passed".
Verified: 4 new integration tests exercise the real column lists against real
Postgres (the mocked suite structurally cannot — it agrees with whatever the
caller asserts, which is why this shipped); from-empty replay for 0042;
hermetic 1534 passed; browser 37/37; oracles clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 31, 2026

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-stagingb5555d5Commit Preview URL

Branch Preview URL
Jul 31 2026, 08:28 PM

@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

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:16 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c7c085ab-bd00-4371-89d8-794663ae61db

📥 Commits

Reviewing files that changed from the base of the PR and between 17353fc and b5555d5.

📒 Files selected for processing (6)
  • backend/db/migrations/0042_assignments_source_gradescope.sql
  • backend/routes/gradescope.py
  • backend/tests/integration/conftest.py
  • backend/tests/integration/test_gradescope_links.py
  • backend/tests/test_gradescope.py
  • docs/decisions/0025-encrypt-rag-chunk-text.md

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.

…ment
Code review found a real bug in the first cut — and it was the exact retake
case I used to JUSTIFY enrollment-keying, which is why it's worth spelling out.
enrollment_id_for re-derives ONE enrollment from the current term on every
call. That's right for CREATING a link and wrong for finding one that already
exists: the seeded user holds CS101 in both fall-2025 and spring-2026, so a
link attached to the enrollment the heuristic doesn't pick was invisible to
every write path. Re-linking created a SECOND row; DELETE removed nothing
while still answering ok:true; sync 400'd "no link" for a course GET /links
was happily listing. The GET/write asymmetry was the tell — list_links
enumerates every enrollment, the writers only ever touched one.
_all_enrollments_for() now spans every enrollment in the course. upsert_link
deletes across all of them before inserting (so re-linking after a rollover
leaves exactly one), remove_link deletes across all of them, and sync_course
keys the whole run on the LINK ROW's enrollment_id rather than a fresh guess —
grades belong to the class instance the link was made against.
Two integration tests cover it, written against the enrollment the resolver
does NOT prefer so the fixture is the hostile case. Both verified red against
the pre-fix code (2 failed / 4 passed) and green after.
Also from review:
- list_links drops rows whose course can't be resolved instead of answering
sapling_course_id: null — a null there reads as a real link the UI can't act
on.
- 0042 switches to NOT VALID. The old comment justified skipping it on the
data ("nothing to grandfather"), which answered the wrong question: the
reason that matters is that a plain ADD CONSTRAINT takes ACCESS EXCLUSIVE on
assignments for the whole validation scan. NOT VALID still enforces every
new and updated row, which is all a widened set needs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 3 issues, all fixed in b5555d5.

  1. The write routes re-derived the enrollment instead of following the existing link — and it was the exact retake case this PR's rationale uses to justify enrollment-keying (bug due to enrollment_id_for resolving one current-term enrollment per call, while list_links enumerates all of them).

The seeded user holds CS101 in both fall-2025 and spring-2026. A link attached to the enrollment the heuristic doesn't pick was invisible to every write path: re-linking created a second row, DELETE removed nothing while still answering ok: true, and sync 400'd "No Gradescope course is linked" for a course GET /links was still listing. The GET/write asymmetry was the tell.

https://github.com/SaplingLearn/Sapling/blob/2c249c2/backend/routes/gradescope.py#L406-L419

Fixed with _all_enrollments_for(): upsert_link deletes across every enrollment in the course before inserting (so a rollover leaves exactly one link), remove_link deletes across all of them, and sync_course keys the run on the link row'senrollment_id rather than a fresh guess — the grades belong to the class instance the link was made against.

Two integration tests cover it, written against the enrollment the resolver does not prefer so the fixture is the hostile case. Verified red against the pre-fix code (2 failed / 4 passed) and green after.

  1. list_links answered sapling_course_id: null for an enrollment whose course couldn't be resolved. Now dropped — a null there reads as a real link the UI has no id to act on.

  2. Migration 0042's rationale answered the wrong question. It justified skipping NOT VALID on the data ("nothing to grandfather"), but the reason that matters is lock behaviour: a plain ADD CONSTRAINT ... CHECK takes ACCESS EXCLUSIVE on assignments for the full validation scan. Switched to NOT VALID, which still enforces every new and updated row — all a widened set needs.

Checked and cleared: the drift audit confirms exactly five points, no sixth (the remaining user_id filters target gradescope_credentials, which is genuinely still user-keyed per 0027); the in.(...) PostgREST syntax is correct; the conftest raise doesn't break any CI workflow (none set RUN_INTEGRATION without a stack).

Noted, not fixed: the conftest fix repairs os.environ but cannot un-freeze a module that already captured a staging value at import. Safe today only because db.connection is imported before those scripts during collection, and nothing asserts that ordering. The general hazard — arbitrary scripts calling load_dotenv(..., override=True) at import time — deserves its own fix in those scripts.

🤖 Generated with Claude Code

@AndresL230
AndresL230 merged commit 37328d6 into mainJul 31, 2026
7 checks passed
AndresL230 added a commit that referenced this pull request Aug 1, 2026
* ci: apply pending migrations to staging on merge to main
Nothing applied them. Verified all four places it could have happened and none
did: the backend image's CMD is a bare uvicorn, there is no Procfile or release
step, main.py's lifespan does not migrate, and the Supabase GitHub integration
reads `supabase/migrations/` — the CLI convention — which this repo does not
have (only config.toml and snippets live under supabase/, and schema_paths is
empty). That is why its check reports "skipping" on every PR: it is connected
but has nothing it recognises. Migrations here are raw DDL under
backend/db/migrations/ applied by db/migrate.py against its own ledger.
So a merge shipped code whose schema had not moved, and someone had to remember
to run it. #504 is live proof: it merged code that writes source='gradescope'
while the CHECK still rejects that value until 0042 is applied.
STAGING ONLY, deliberately. main deploys staging; prod is a separate
`production` branch promotion, and auto-applying irreversible DDL to prod on
merge is a different risk decision. This runner has no down migrations.
Two safety properties, both exercised against the real local database rather
than assumed:
- No secret set -> notice + skip, so adding this file changes nothing until
STAGING_SUPABASE_DB_URL exists.
- Preflight refuses to apply on a drifted ledger: a missing schema_migrations
table (the #317 shape) or any recorded-but-absent filename fails the job
with the offending name, instead of pushing more DDL on top of a history
the repo and database already disagree about.
Tested three ways: healthy (45 on disk / 45 recorded / 0 pending, exit 0),
injected drift (exit 1, names the ghost row), and absent ledger (exit 1).
The first draft queried a `version` column; the ledger's column is `filename`,
which only the real-database test caught.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* ci: restrict the migrate job to main; pin psycopg range
Code review found a real hole. workflow_dispatch lets you pick ANY branch
containing the workflow file, so a migration could be applied to shared staging
straight from an unmerged branch, bypassing the push-to-main gate the whole
design assumes.
The bypass is not the worst part. The filename lands in schema_migrations, so
if the file is then edited before merging — easy, since it was only "tested" —
the merge never re-applies it, and staging silently diverges from the canonical
file with NO pending/orphan signal, because the recorded filename still
matches. That is precisely the immutability rule CLAUDE.md states, violated
without a trace. Job-level `if: github.ref == 'refs/heads/main'` closes it.
Also pins psycopg to >=3.2,<4 to match backend/requirements.txt, so the runner
can't silently drift onto a major the app has never run against.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@AndresL230
AndresL230 deleted the fix/265-gradescope-enrollment-keyed branch August 2, 2026 18:30
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)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(gradescope): rewire onto the enrollment-keyed schema (#265) - #504

Merged
AndresL230 merged 3 commits into
mainfrom
fix/265-gradescope-enrollment-keyed
Jul 31, 2026
Merged

fix(gradescope): rewire onto the enrollment-keyed schema (#265)#504
AndresL230 merged 3 commits into
mainfrom
fix/265-gradescope-enrollment-keyed

Conversation

@AndresL230

Copy link
Copy Markdown
Collaborator

Part of #265.

Five drift points, not one

routes/gradescope.py was never migrated past the DB redesign. It named five things the schema does not have, so every link/sync/delete returned a PostgREST 400 in production — the same failure mode as #405:

#Code saysSchema says
1table user_coursesrenamed enrollments (0020)
2gradescope_course_links.user_id / .sapling_course_idenrollment_id (0027)
3assignments.user_id / .course_idenrollment-scoped (0021)
4table course_categoriesgradebook_categories (0021)
5writes source='gradescope'CHECK allows only {manual, syllabus}

Point 5 matters: even a perfect column fix would still have failed every insert, because the sync had no legal value to write. Migration 0042 widens the CHECK — a strict superset, so it is added VALIDATED with nothing to grandfather.

The UX question the issue asked to confirm

Answered enrollment-keyed. Grades land on enrollment-scoped assignments, so a link has to name the specific class instance — a retake gives two enrollments and an abstract-course link cannot say which term's assignments to write. The schema already said this in 0027; the code hadn't caught up.

Per the repo convention the HTTP boundary keeps the abstract course_id and resolves inward through the existing academics.enrollment_id_for, so the frontend contract is unchangedlist_links maps enrollment rows back out to course ids on the way through.

The reason this survived the lane built to catch it

RUN_INTEGRATION=1 pytest -m integration — the documented invocation — ran 1 passed, 27 skipped and exited 0. It reported green having tested nothing.

Collecting the whole tree imports tests/test_benchmark_quiz.py and tests/test_seed_quiz_fixture.py, which import scripts that call load_dotenv(".env.staging", override=True) at module import. That clobbers SUPABASE_URL to staging for the rest of the session, so _require_local_stack saw a non-local URL and skipped all 27. pytest tests/integration/ (directory form) ran fine on the same stack, which is presumably why it looked healthy.

The session fixture now re-asserts the local env at run time — not only at conftest import, which happens before those script imports — and raises rather than skipping when it is still wrong. That is the rule the same file already applied to SUPABASE_DB_URL ("raises loudly rather than skipping... a silent skip would read as safe"), now applied to SUPABASE_URL too.

Documented form: 1 passed / 27 skipped → 28 passed.

I include this here rather than splitting it because the four new integration tests are #265's regression guard, and a guard that silently skips is not one.

Verification

  • 4 new integration tests drive POST /link, GET /links, DELETE /link/{id} against real Postgres and read back with raw SQL — the assertions that would have caught the original drift. The mocked suite structurally cannot: it agrees with whatever column list the caller names.
  • Unit tests pin the shape (enrollment_id written, never the course id) so a regression back to the abstract id fails fast.
  • From-empty replay for 0042; hermetic 1534 passed; integration 28 passed; browser 37/37; oracles 0 findings.

Not covered:POST /sync/{id} drives a live Gradescope login and scrape, so its DB surface has no offline seam. Stated in the test file rather than left implicit.

🤖 Generated with Claude Code

AndresL230and others added 2 commits July 31, 2026 12:42
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>
routes/gradescope.py was never migrated past the DB redesign. It named FIVE
things the schema doesn't have, so every link/sync/delete returned a PostgREST
400 in production — the same failure mode as #405:
1. table user_courses -> renamed to enrollments in 0020
2. gradescope_course_links.user_id / .sapling_course_id
-> 0027 keys the table on enrollment_id
3. assignments.user_id / .course_id
-> 0021 made assignments enrollment-scoped
4. table course_categories -> renamed gradebook_categories in 0021
5. assignments.source CHECK allowed only {manual,syllabus}, but the sync
writes source='gradescope' — so even with the columns fixed there was no
legal value to write. Migration 0042 widens it (strict superset, so the
constraint is added VALIDATED).
The issue asked to confirm the intended UX. Enrollment-keyed is right: grades
land on enrollment-scoped assignments, so a link has to name the specific class
instance — a retake gives two enrollments and an abstract-course link cannot
say which term's assignments to write. The schema already said this; the code
hadn't caught up.
Per the repo convention the HTTP boundary keeps the abstract course_id and
resolves inward via academics.enrollment_id_for, so the FRONTEND CONTRACT IS
UNCHANGED — list_links maps enrollment rows back out to course ids.
Also fixes the reason this drift survived the lane built to catch it.
`RUN_INTEGRATION=1 pytest -m integration` — the documented invocation —
silently ran NOTHING and exited 0. Collecting the whole tree imports
tests/test_benchmark_quiz.py and tests/test_seed_quiz_fixture.py, which import
scripts that call load_dotenv(".env.staging", override=True) at module import;
that clobbered SUPABASE_URL for the session, so _require_local_stack skipped
all 27 integration tests. The session fixture now re-asserts the local env at
run time and RAISES rather than skipping when it is still wrong — the rule the
file already applied to SUPABASE_DB_URL, now applied to SUPABASE_URL too.
Documented form went from "1 passed, 27 skipped" to "28 passed".
Verified: 4 new integration tests exercise the real column lists against real
Postgres (the mocked suite structurally cannot — it agrees with whatever the
caller asserts, which is why this shipped); from-empty replay for 0042;
hermetic 1534 passed; browser 37/37; oracles clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 31, 2026

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-stagingb5555d5Commit Preview URL

Branch Preview URL
Jul 31 2026, 08:28 PM

@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

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:16 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c7c085ab-bd00-4371-89d8-794663ae61db

📥 Commits

Reviewing files that changed from the base of the PR and between 17353fc and b5555d5.

📒 Files selected for processing (6)
  • backend/db/migrations/0042_assignments_source_gradescope.sql
  • backend/routes/gradescope.py
  • backend/tests/integration/conftest.py
  • backend/tests/integration/test_gradescope_links.py
  • backend/tests/test_gradescope.py
  • docs/decisions/0025-encrypt-rag-chunk-text.md

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.

…ment
Code review found a real bug in the first cut — and it was the exact retake
case I used to JUSTIFY enrollment-keying, which is why it's worth spelling out.
enrollment_id_for re-derives ONE enrollment from the current term on every
call. That's right for CREATING a link and wrong for finding one that already
exists: the seeded user holds CS101 in both fall-2025 and spring-2026, so a
link attached to the enrollment the heuristic doesn't pick was invisible to
every write path. Re-linking created a SECOND row; DELETE removed nothing
while still answering ok:true; sync 400'd "no link" for a course GET /links
was happily listing. The GET/write asymmetry was the tell — list_links
enumerates every enrollment, the writers only ever touched one.
_all_enrollments_for() now spans every enrollment in the course. upsert_link
deletes across all of them before inserting (so re-linking after a rollover
leaves exactly one), remove_link deletes across all of them, and sync_course
keys the whole run on the LINK ROW's enrollment_id rather than a fresh guess —
grades belong to the class instance the link was made against.
Two integration tests cover it, written against the enrollment the resolver
does NOT prefer so the fixture is the hostile case. Both verified red against
the pre-fix code (2 failed / 4 passed) and green after.
Also from review:
- list_links drops rows whose course can't be resolved instead of answering
sapling_course_id: null — a null there reads as a real link the UI can't act
on.
- 0042 switches to NOT VALID. The old comment justified skipping it on the
data ("nothing to grandfather"), which answered the wrong question: the
reason that matters is that a plain ADD CONSTRAINT takes ACCESS EXCLUSIVE on
assignments for the whole validation scan. NOT VALID still enforces every
new and updated row, which is all a widened set needs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 3 issues, all fixed in b5555d5.

  1. The write routes re-derived the enrollment instead of following the existing link — and it was the exact retake case this PR's rationale uses to justify enrollment-keying (bug due to enrollment_id_for resolving one current-term enrollment per call, while list_links enumerates all of them).

The seeded user holds CS101 in both fall-2025 and spring-2026. A link attached to the enrollment the heuristic doesn't pick was invisible to every write path: re-linking created a second row, DELETE removed nothing while still answering ok: true, and sync 400'd "No Gradescope course is linked" for a course GET /links was still listing. The GET/write asymmetry was the tell.

https://github.com/SaplingLearn/Sapling/blob/2c249c2/backend/routes/gradescope.py#L406-L419

Fixed with _all_enrollments_for(): upsert_link deletes across every enrollment in the course before inserting (so a rollover leaves exactly one link), remove_link deletes across all of them, and sync_course keys the run on the link row'senrollment_id rather than a fresh guess — the grades belong to the class instance the link was made against.

Two integration tests cover it, written against the enrollment the resolver does not prefer so the fixture is the hostile case. Verified red against the pre-fix code (2 failed / 4 passed) and green after.

  1. list_links answered sapling_course_id: null for an enrollment whose course couldn't be resolved. Now dropped — a null there reads as a real link the UI has no id to act on.

  2. Migration 0042's rationale answered the wrong question. It justified skipping NOT VALID on the data ("nothing to grandfather"), but the reason that matters is lock behaviour: a plain ADD CONSTRAINT ... CHECK takes ACCESS EXCLUSIVE on assignments for the full validation scan. Switched to NOT VALID, which still enforces every new and updated row — all a widened set needs.

Checked and cleared: the drift audit confirms exactly five points, no sixth (the remaining user_id filters target gradescope_credentials, which is genuinely still user-keyed per 0027); the in.(...) PostgREST syntax is correct; the conftest raise doesn't break any CI workflow (none set RUN_INTEGRATION without a stack).

Noted, not fixed: the conftest fix repairs os.environ but cannot un-freeze a module that already captured a staging value at import. Safe today only because db.connection is imported before those scripts during collection, and nothing asserts that ordering. The general hazard — arbitrary scripts calling load_dotenv(..., override=True) at import time — deserves its own fix in those scripts.

🤖 Generated with Claude Code

@AndresL230
AndresL230 merged commit 37328d6 into mainJul 31, 2026
7 checks passed
AndresL230 added a commit that referenced this pull request Aug 1, 2026
* ci: apply pending migrations to staging on merge to main
Nothing applied them. Verified all four places it could have happened and none
did: the backend image's CMD is a bare uvicorn, there is no Procfile or release
step, main.py's lifespan does not migrate, and the Supabase GitHub integration
reads `supabase/migrations/` — the CLI convention — which this repo does not
have (only config.toml and snippets live under supabase/, and schema_paths is
empty). That is why its check reports "skipping" on every PR: it is connected
but has nothing it recognises. Migrations here are raw DDL under
backend/db/migrations/ applied by db/migrate.py against its own ledger.
So a merge shipped code whose schema had not moved, and someone had to remember
to run it. #504 is live proof: it merged code that writes source='gradescope'
while the CHECK still rejects that value until 0042 is applied.
STAGING ONLY, deliberately. main deploys staging; prod is a separate
`production` branch promotion, and auto-applying irreversible DDL to prod on
merge is a different risk decision. This runner has no down migrations.
Two safety properties, both exercised against the real local database rather
than assumed:
- No secret set -> notice + skip, so adding this file changes nothing until
STAGING_SUPABASE_DB_URL exists.
- Preflight refuses to apply on a drifted ledger: a missing schema_migrations
table (the #317 shape) or any recorded-but-absent filename fails the job
with the offending name, instead of pushing more DDL on top of a history
the repo and database already disagree about.
Tested three ways: healthy (45 on disk / 45 recorded / 0 pending, exit 0),
injected drift (exit 1, names the ghost row), and absent ledger (exit 1).
The first draft queried a `version` column; the ledger's column is `filename`,
which only the real-database test caught.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* ci: restrict the migrate job to main; pin psycopg range
Code review found a real hole. workflow_dispatch lets you pick ANY branch
containing the workflow file, so a migration could be applied to shared staging
straight from an unmerged branch, bypassing the push-to-main gate the whole
design assumes.
The bypass is not the worst part. The filename lands in schema_migrations, so
if the file is then edited before merging — easy, since it was only "tested" —
the merge never re-applies it, and staging silently diverges from the canonical
file with NO pending/orphan signal, because the recorded filename still
matches. That is precisely the immutability rule CLAUDE.md states, violated
without a trace. Job-level `if: github.ref == 'refs/heads/main'` closes it.
Also pins psycopg to >=3.2,<4 to match backend/requirements.txt, so the runner
can't silently drift onto a major the app has never run against.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@AndresL230
AndresL230 deleted the fix/265-gradescope-enrollment-keyed branch August 2, 2026 18:30
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)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(gradescope): rewire onto the enrollment-keyed schema (#265) - #504

Merged
AndresL230 merged 3 commits into
mainfrom
fix/265-gradescope-enrollment-keyed
Jul 31, 2026
Merged

fix(gradescope): rewire onto the enrollment-keyed schema (#265)#504
AndresL230 merged 3 commits into
mainfrom
fix/265-gradescope-enrollment-keyed

Conversation

@AndresL230

Copy link
Copy Markdown
Collaborator

Part of #265.

Five drift points, not one

routes/gradescope.py was never migrated past the DB redesign. It named five things the schema does not have, so every link/sync/delete returned a PostgREST 400 in production — the same failure mode as #405:

#Code saysSchema says
1table user_coursesrenamed enrollments (0020)
2gradescope_course_links.user_id / .sapling_course_idenrollment_id (0027)
3assignments.user_id / .course_idenrollment-scoped (0021)
4table course_categoriesgradebook_categories (0021)
5writes source='gradescope'CHECK allows only {manual, syllabus}

Point 5 matters: even a perfect column fix would still have failed every insert, because the sync had no legal value to write. Migration 0042 widens the CHECK — a strict superset, so it is added VALIDATED with nothing to grandfather.

The UX question the issue asked to confirm

Answered enrollment-keyed. Grades land on enrollment-scoped assignments, so a link has to name the specific class instance — a retake gives two enrollments and an abstract-course link cannot say which term's assignments to write. The schema already said this in 0027; the code hadn't caught up.

Per the repo convention the HTTP boundary keeps the abstract course_id and resolves inward through the existing academics.enrollment_id_for, so the frontend contract is unchangedlist_links maps enrollment rows back out to course ids on the way through.

The reason this survived the lane built to catch it

RUN_INTEGRATION=1 pytest -m integration — the documented invocation — ran 1 passed, 27 skipped and exited 0. It reported green having tested nothing.

Collecting the whole tree imports tests/test_benchmark_quiz.py and tests/test_seed_quiz_fixture.py, which import scripts that call load_dotenv(".env.staging", override=True) at module import. That clobbers SUPABASE_URL to staging for the rest of the session, so _require_local_stack saw a non-local URL and skipped all 27. pytest tests/integration/ (directory form) ran fine on the same stack, which is presumably why it looked healthy.

The session fixture now re-asserts the local env at run time — not only at conftest import, which happens before those script imports — and raises rather than skipping when it is still wrong. That is the rule the same file already applied to SUPABASE_DB_URL ("raises loudly rather than skipping... a silent skip would read as safe"), now applied to SUPABASE_URL too.

Documented form: 1 passed / 27 skipped → 28 passed.

I include this here rather than splitting it because the four new integration tests are #265's regression guard, and a guard that silently skips is not one.

Verification

  • 4 new integration tests drive POST /link, GET /links, DELETE /link/{id} against real Postgres and read back with raw SQL — the assertions that would have caught the original drift. The mocked suite structurally cannot: it agrees with whatever column list the caller names.
  • Unit tests pin the shape (enrollment_id written, never the course id) so a regression back to the abstract id fails fast.
  • From-empty replay for 0042; hermetic 1534 passed; integration 28 passed; browser 37/37; oracles 0 findings.

Not covered:POST /sync/{id} drives a live Gradescope login and scrape, so its DB surface has no offline seam. Stated in the test file rather than left implicit.

🤖 Generated with Claude Code

AndresL230and others added 2 commits July 31, 2026 12:42
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>
routes/gradescope.py was never migrated past the DB redesign. It named FIVE
things the schema doesn't have, so every link/sync/delete returned a PostgREST
400 in production — the same failure mode as #405:
1. table user_courses -> renamed to enrollments in 0020
2. gradescope_course_links.user_id / .sapling_course_id
-> 0027 keys the table on enrollment_id
3. assignments.user_id / .course_id
-> 0021 made assignments enrollment-scoped
4. table course_categories -> renamed gradebook_categories in 0021
5. assignments.source CHECK allowed only {manual,syllabus}, but the sync
writes source='gradescope' — so even with the columns fixed there was no
legal value to write. Migration 0042 widens it (strict superset, so the
constraint is added VALIDATED).
The issue asked to confirm the intended UX. Enrollment-keyed is right: grades
land on enrollment-scoped assignments, so a link has to name the specific class
instance — a retake gives two enrollments and an abstract-course link cannot
say which term's assignments to write. The schema already said this; the code
hadn't caught up.
Per the repo convention the HTTP boundary keeps the abstract course_id and
resolves inward via academics.enrollment_id_for, so the FRONTEND CONTRACT IS
UNCHANGED — list_links maps enrollment rows back out to course ids.
Also fixes the reason this drift survived the lane built to catch it.
`RUN_INTEGRATION=1 pytest -m integration` — the documented invocation —
silently ran NOTHING and exited 0. Collecting the whole tree imports
tests/test_benchmark_quiz.py and tests/test_seed_quiz_fixture.py, which import
scripts that call load_dotenv(".env.staging", override=True) at module import;
that clobbered SUPABASE_URL for the session, so _require_local_stack skipped
all 27 integration tests. The session fixture now re-asserts the local env at
run time and RAISES rather than skipping when it is still wrong — the rule the
file already applied to SUPABASE_DB_URL, now applied to SUPABASE_URL too.
Documented form went from "1 passed, 27 skipped" to "28 passed".
Verified: 4 new integration tests exercise the real column lists against real
Postgres (the mocked suite structurally cannot — it agrees with whatever the
caller asserts, which is why this shipped); from-empty replay for 0042;
hermetic 1534 passed; browser 37/37; oracles clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 31, 2026

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-stagingb5555d5Commit Preview URL

Branch Preview URL
Jul 31 2026, 08:28 PM

@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

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:16 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c7c085ab-bd00-4371-89d8-794663ae61db

📥 Commits

Reviewing files that changed from the base of the PR and between 17353fc and b5555d5.

📒 Files selected for processing (6)
  • backend/db/migrations/0042_assignments_source_gradescope.sql
  • backend/routes/gradescope.py
  • backend/tests/integration/conftest.py
  • backend/tests/integration/test_gradescope_links.py
  • backend/tests/test_gradescope.py
  • docs/decisions/0025-encrypt-rag-chunk-text.md

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.

…ment
Code review found a real bug in the first cut — and it was the exact retake
case I used to JUSTIFY enrollment-keying, which is why it's worth spelling out.
enrollment_id_for re-derives ONE enrollment from the current term on every
call. That's right for CREATING a link and wrong for finding one that already
exists: the seeded user holds CS101 in both fall-2025 and spring-2026, so a
link attached to the enrollment the heuristic doesn't pick was invisible to
every write path. Re-linking created a SECOND row; DELETE removed nothing
while still answering ok:true; sync 400'd "no link" for a course GET /links
was happily listing. The GET/write asymmetry was the tell — list_links
enumerates every enrollment, the writers only ever touched one.
_all_enrollments_for() now spans every enrollment in the course. upsert_link
deletes across all of them before inserting (so re-linking after a rollover
leaves exactly one), remove_link deletes across all of them, and sync_course
keys the whole run on the LINK ROW's enrollment_id rather than a fresh guess —
grades belong to the class instance the link was made against.
Two integration tests cover it, written against the enrollment the resolver
does NOT prefer so the fixture is the hostile case. Both verified red against
the pre-fix code (2 failed / 4 passed) and green after.
Also from review:
- list_links drops rows whose course can't be resolved instead of answering
sapling_course_id: null — a null there reads as a real link the UI can't act
on.
- 0042 switches to NOT VALID. The old comment justified skipping it on the
data ("nothing to grandfather"), which answered the wrong question: the
reason that matters is that a plain ADD CONSTRAINT takes ACCESS EXCLUSIVE on
assignments for the whole validation scan. NOT VALID still enforces every
new and updated row, which is all a widened set needs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 3 issues, all fixed in b5555d5.

  1. The write routes re-derived the enrollment instead of following the existing link — and it was the exact retake case this PR's rationale uses to justify enrollment-keying (bug due to enrollment_id_for resolving one current-term enrollment per call, while list_links enumerates all of them).

The seeded user holds CS101 in both fall-2025 and spring-2026. A link attached to the enrollment the heuristic doesn't pick was invisible to every write path: re-linking created a second row, DELETE removed nothing while still answering ok: true, and sync 400'd "No Gradescope course is linked" for a course GET /links was still listing. The GET/write asymmetry was the tell.

https://github.com/SaplingLearn/Sapling/blob/2c249c2/backend/routes/gradescope.py#L406-L419

Fixed with _all_enrollments_for(): upsert_link deletes across every enrollment in the course before inserting (so a rollover leaves exactly one link), remove_link deletes across all of them, and sync_course keys the run on the link row'senrollment_id rather than a fresh guess — the grades belong to the class instance the link was made against.

Two integration tests cover it, written against the enrollment the resolver does not prefer so the fixture is the hostile case. Verified red against the pre-fix code (2 failed / 4 passed) and green after.

  1. list_links answered sapling_course_id: null for an enrollment whose course couldn't be resolved. Now dropped — a null there reads as a real link the UI has no id to act on.

  2. Migration 0042's rationale answered the wrong question. It justified skipping NOT VALID on the data ("nothing to grandfather"), but the reason that matters is lock behaviour: a plain ADD CONSTRAINT ... CHECK takes ACCESS EXCLUSIVE on assignments for the full validation scan. Switched to NOT VALID, which still enforces every new and updated row — all a widened set needs.

Checked and cleared: the drift audit confirms exactly five points, no sixth (the remaining user_id filters target gradescope_credentials, which is genuinely still user-keyed per 0027); the in.(...) PostgREST syntax is correct; the conftest raise doesn't break any CI workflow (none set RUN_INTEGRATION without a stack).

Noted, not fixed: the conftest fix repairs os.environ but cannot un-freeze a module that already captured a staging value at import. Safe today only because db.connection is imported before those scripts during collection, and nothing asserts that ordering. The general hazard — arbitrary scripts calling load_dotenv(..., override=True) at import time — deserves its own fix in those scripts.

🤖 Generated with Claude Code

@AndresL230
AndresL230 merged commit 37328d6 into mainJul 31, 2026
7 checks passed
AndresL230 added a commit that referenced this pull request Aug 1, 2026
* ci: apply pending migrations to staging on merge to main
Nothing applied them. Verified all four places it could have happened and none
did: the backend image's CMD is a bare uvicorn, there is no Procfile or release
step, main.py's lifespan does not migrate, and the Supabase GitHub integration
reads `supabase/migrations/` — the CLI convention — which this repo does not
have (only config.toml and snippets live under supabase/, and schema_paths is
empty). That is why its check reports "skipping" on every PR: it is connected
but has nothing it recognises. Migrations here are raw DDL under
backend/db/migrations/ applied by db/migrate.py against its own ledger.
So a merge shipped code whose schema had not moved, and someone had to remember
to run it. #504 is live proof: it merged code that writes source='gradescope'
while the CHECK still rejects that value until 0042 is applied.
STAGING ONLY, deliberately. main deploys staging; prod is a separate
`production` branch promotion, and auto-applying irreversible DDL to prod on
merge is a different risk decision. This runner has no down migrations.
Two safety properties, both exercised against the real local database rather
than assumed:
- No secret set -> notice + skip, so adding this file changes nothing until
STAGING_SUPABASE_DB_URL exists.
- Preflight refuses to apply on a drifted ledger: a missing schema_migrations
table (the #317 shape) or any recorded-but-absent filename fails the job
with the offending name, instead of pushing more DDL on top of a history
the repo and database already disagree about.
Tested three ways: healthy (45 on disk / 45 recorded / 0 pending, exit 0),
injected drift (exit 1, names the ghost row), and absent ledger (exit 1).
The first draft queried a `version` column; the ledger's column is `filename`,
which only the real-database test caught.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* ci: restrict the migrate job to main; pin psycopg range
Code review found a real hole. workflow_dispatch lets you pick ANY branch
containing the workflow file, so a migration could be applied to shared staging
straight from an unmerged branch, bypassing the push-to-main gate the whole
design assumes.
The bypass is not the worst part. The filename lands in schema_migrations, so
if the file is then edited before merging — easy, since it was only "tested" —
the merge never re-applies it, and staging silently diverges from the canonical
file with NO pending/orphan signal, because the recorded filename still
matches. That is precisely the immutability rule CLAUDE.md states, violated
without a trace. Job-level `if: github.ref == 'refs/heads/main'` closes it.
Also pins psycopg to >=3.2,<4 to match backend/requirements.txt, so the runner
can't silently drift onto a major the app has never run against.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@AndresL230
AndresL230 deleted the fix/265-gradescope-enrollment-keyed branch August 2, 2026 18:30
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