feat(quiz): encrypt quiz performance JSON at rest (#521) - #527

Merged
AndresL230 merged 4 commits into
mainfrom
feat/522-c-521-quiz
Aug 6, 2026
Merged

feat(quiz): encrypt quiz performance JSON at rest (#521)#527
AndresL230 merged 4 commits into
mainfrom
feat/522-c-521-quiz

Conversation

@AndresL230

Copy link
Copy Markdown
Collaborator

Closes#521.

  • quiz_attempts.questions_json/answers_json + quiz_context.context_json via encrypt_json, matching sessions.summary_json
  • decrypt at all four readers (submit scoring, quiz_context_service, quiz_history agent tool, course_context) — always before prompts and before the lru cache
  • scalars (score/total/difficulty/completed_at) untouched; analytics unaffected
  • new decrypt_json_column guard: legacy plaintext JSONB rows keep working pre-backfill (tested)
  • backfill runners, encrypted seed, roundtrip tests, ciphertext-oracle entries

Stacked on #520's PR.

🤖 Generated with Claude Code

@coderabbitai

coderabbitaiBot commented Aug 5, 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:55 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

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: 14afac61-08f8-4a7c-a15e-08a9c9ba25b0

📥 Commits

Reviewing files that changed from the base of the PR and between 01fea19 and 3e78b3e.

📒 Files selected for processing (14)
  • CLAUDE.md
  • backend/agents/tools/quiz_history.py
  • backend/db/backfill_encryption.py
  • backend/db/seed_local_rich.py
  • backend/e2e_oracles/gather.py
  • backend/routes/quiz.py
  • backend/services/course_context_service.py
  • backend/services/encryption.py
  • backend/services/quiz_context_service.py
  • backend/tests/integration/test_encryption_roundtrip.py
  • backend/tests/test_encryption_json_column.py
  • backend/tests/test_quiz_context_service.py
  • backend/tests/test_quiz_history_tool.py
  • backend/tests/test_quiz_routes.py

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.

@supabase

supabaseBot commented Aug 5, 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 ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 5, 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-staging1bc62c2Commit Preview URL

Branch Preview URL
Aug 06 2026, 01:16 AM

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 issue:

  1. get_quiz_context returns decrypt_json_column(rows[0]["context_json"]) with no guard, and submit_quiz calls it after the atomic completed_at claim, the mastery write, and the score/answers_json update. An undecryptable quiz_context row (corruption, key mismatch) turns an already-scored submission into an unhandled 500 — the client never receives the score, quiz.completed never logs, and a retry 409s forever. Every other decrypt_json/decrypt_json_column call site degrades instead (the convention is documented at routes/documents.py:250-253, and this PR's own quiz_history.py and course_context_service.py sites are wrapped) — this is the one site that skips it, in the worst place to skip it.

)
ifrows:
returndecrypt_json_column(rows[0]["context_json"])
returnNone

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@AndresL230
AndresL230force-pushed the feat/522-b-520-feedback branch from 3052b3a to 1e36394CompareAugust 6, 2026 01:21
@AndresL230
AndresL230 changed the base branch from feat/522-b-520-feedback to mainAugust 6, 2026 01:25
AndresL230and others added 4 commits August 5, 2026 21:25
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#521)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rypt; boundary tests (#521)
get_quiz_context ran unguarded post-commit in submit_quiz — a corrupt
context_json 500ed a request whose mastery/score writes already landed,
and every retry then hit the completed_at 409 with no recovery path.
Wrap the decrypt in try/except (mirrors documents.py's degrade
convention) and log + return None instead of raising.
Also reorders submit_quiz so require_self(user_id, request) runs before
decrypt_json_column(questions_json) — auth before decrypt work.
Adds tests/test_quiz_context_service.py (FakeTable pattern) covering
the ciphertext upsert (on_conflict="user_id,concept_node_id" asserted),
ciphertext + legacy-plaintext reads, and the corrupt-string guard
(verified to fail via stash/run/pop when the guard is reverted).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230
AndresL230 merged commit c19a880 into mainAug 6, 2026
7 checks passed
@AndresL230
AndresL230 deleted the feat/522-c-521-quiz branch August 6, 2026 01:28
AndresL230 added a commit that referenced this pull request Aug 13, 2026
…L sweep, resume, paginated history (#542) (#550)
* feat(quiz): attempt lifecycle — mastery snapshot, derived status + TTL sweep, resume, paginated history (#542)
Workstream D of the pre-revamp quiz repair batch (epic #537):
D1 — submit persists mastery_before/mastery_after on the attempt row
(migration 20260813013547; plaintext analytics scalars per #521), so a
replayed or audited submit can reconstruct what the student saw.
D2 — status is DERIVED, never stored: completed_at → completed,
abandoned_at → abandoned, else in_progress; an in-progress row past
QUIZ_ATTEMPT_ABANDON_TTL_HOURS (24h, documented) reads as abandoned even
before the lazy per-user sweep stamps abandoned_at on the read paths
(no scheduler needed; conditional-update filters arbitrate).
GET /api/quiz/attempts/{id} returns resume state: questions WITHOUT the
answer key plus the responses recorded through /answer.
D3 — quizzes_completed counts completed attempts only; generate writes
the attempt row before the student answers anything, so the unfiltered
count let "generate and close the tab" advance quizzes_10. Blast radius
(measured on staging 2026-08-12): 1 user, 2 attempts, 0 completed,
badge never granted — nobody loses anything; prod recheck noted in the
PR. No revocation in any case.
D4 — GET /api/quiz/attempts: paginated history (concept, course, score,
total, difficulty, mastery delta, dates) — the plaintext scalars kept
for exactly this purpose in #521/#527 finally have a reader.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(quiz): real-DB coverage for the attempt lifecycle columns and sweep (#542)
The mastery snapshot and abandonment sweep are pure DB behaviour: a
mocked table() accepts columns the migration never added (#265 drift
class), and the conditional-update filters that decide WHICH attempts get
swept only mean something against Postgres. Asserts the columns
round-trip and that the sweep touches the stale in-progress attempt while
leaving a fresh one and a completed one alone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(quiz): address #550 review — close the resume answer leak, make abandoned bite, harden history (#542)
Review findings (xhigh, 15 confirmed). The serious ones:
- The resume endpoint leaked the answer twice over: _strip_answer_key
dropped only per-option `correct` and shipped `explanation` (which
names the answer in prose), and being a DENYLIST it passed unknown
stored shapes straight through — the rich seed's legacy row exposes
its answer under `a` while getting an empty options list. It is an
ALLOWLIST now (id/question/concept_tested/difficulty + label/text),
and an unrecognised stored shape 409s QUIZ_ATTEMPT_NOT_RESUMABLE
rather than being projected at all.
- "Abandoned" was cosmetic: resume served the full question set and both
/answer and /submit accepted swept attempts, paying out mastery, XP and
achievements. Both write paths now 409 QUIZ_ATTEMPT_ABANDONED, and
resume returns no questions with resumable:false.
- quizzes_completed counted claimed-but-never-graded attempts, because
completed_at is stamped by the atomic claim BEFORE grading. It now also
requires a persisted score.
- The TTL keyed on created_at alone, so an attempt being actively
answered was swept. Status and sweep both consider the newest recorded
answer; the active attempt is exempted from the sweep.
- The stored mastery snapshot was submit's local prediction; it now
records what apply_graph_update actually wrote (it clamps and resolves
by concept name), so history can't show progression the graph refused.
- The sweep is a write on a GET: it now sends Prefer: return=minimal
(new db/connection.py option) instead of dragging every swept row —
encrypted blobs included — back on each history page load.
- history: offset clamped at the top (an unbounded value 500s as
bigint-out-of-range) and ordered created_at.desc,id.desc so rows
sharing a timestamp can't repeat or vanish across pages.
- _attempt_status parsed naive timestamps fine and then raised TypeError
comparing them to an aware cutoff, past the ValueError guard; a shared
_parse_ts assumes UTC for naive values.
- Migration DDL is idempotent (IF NOT EXISTS) per the repo rule, plus a
partial index for the sweep/history predicate.
Test gaps the review found: the history "no question payloads" assertion
was vacuous (it now asserts the SELECTED COLUMNS), and neither new GET
had ownership coverage — the hermetic lane structurally cannot provide it
(require_self is stubbed there), so the IDOR negatives live in the
integration lane with real sessions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

Quiz performance data stored in plaintext (questions_json, answers_json, quiz_context)

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(quiz): encrypt quiz performance JSON at rest (#521) - #527

Merged
AndresL230 merged 4 commits into
mainfrom
feat/522-c-521-quiz
Aug 6, 2026
Merged

feat(quiz): encrypt quiz performance JSON at rest (#521)#527
AndresL230 merged 4 commits into
mainfrom
feat/522-c-521-quiz

Conversation

@AndresL230

Copy link
Copy Markdown
Collaborator

Closes#521.

  • quiz_attempts.questions_json/answers_json + quiz_context.context_json via encrypt_json, matching sessions.summary_json
  • decrypt at all four readers (submit scoring, quiz_context_service, quiz_history agent tool, course_context) — always before prompts and before the lru cache
  • scalars (score/total/difficulty/completed_at) untouched; analytics unaffected
  • new decrypt_json_column guard: legacy plaintext JSONB rows keep working pre-backfill (tested)
  • backfill runners, encrypted seed, roundtrip tests, ciphertext-oracle entries

Stacked on #520's PR.

🤖 Generated with Claude Code

@coderabbitai

coderabbitaiBot commented Aug 5, 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:55 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

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: 14afac61-08f8-4a7c-a15e-08a9c9ba25b0

📥 Commits

Reviewing files that changed from the base of the PR and between 01fea19 and 3e78b3e.

📒 Files selected for processing (14)
  • CLAUDE.md
  • backend/agents/tools/quiz_history.py
  • backend/db/backfill_encryption.py
  • backend/db/seed_local_rich.py
  • backend/e2e_oracles/gather.py
  • backend/routes/quiz.py
  • backend/services/course_context_service.py
  • backend/services/encryption.py
  • backend/services/quiz_context_service.py
  • backend/tests/integration/test_encryption_roundtrip.py
  • backend/tests/test_encryption_json_column.py
  • backend/tests/test_quiz_context_service.py
  • backend/tests/test_quiz_history_tool.py
  • backend/tests/test_quiz_routes.py

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.

@supabase

supabaseBot commented Aug 5, 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 ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 5, 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-staging1bc62c2Commit Preview URL

Branch Preview URL
Aug 06 2026, 01:16 AM

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 issue:

  1. get_quiz_context returns decrypt_json_column(rows[0]["context_json"]) with no guard, and submit_quiz calls it after the atomic completed_at claim, the mastery write, and the score/answers_json update. An undecryptable quiz_context row (corruption, key mismatch) turns an already-scored submission into an unhandled 500 — the client never receives the score, quiz.completed never logs, and a retry 409s forever. Every other decrypt_json/decrypt_json_column call site degrades instead (the convention is documented at routes/documents.py:250-253, and this PR's own quiz_history.py and course_context_service.py sites are wrapped) — this is the one site that skips it, in the worst place to skip it.

)
ifrows:
returndecrypt_json_column(rows[0]["context_json"])
returnNone

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@AndresL230
AndresL230force-pushed the feat/522-b-520-feedback branch from 3052b3a to 1e36394CompareAugust 6, 2026 01:21
@AndresL230
AndresL230 changed the base branch from feat/522-b-520-feedback to mainAugust 6, 2026 01:25
AndresL230and others added 4 commits August 5, 2026 21:25
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#521)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rypt; boundary tests (#521)
get_quiz_context ran unguarded post-commit in submit_quiz — a corrupt
context_json 500ed a request whose mastery/score writes already landed,
and every retry then hit the completed_at 409 with no recovery path.
Wrap the decrypt in try/except (mirrors documents.py's degrade
convention) and log + return None instead of raising.
Also reorders submit_quiz so require_self(user_id, request) runs before
decrypt_json_column(questions_json) — auth before decrypt work.
Adds tests/test_quiz_context_service.py (FakeTable pattern) covering
the ciphertext upsert (on_conflict="user_id,concept_node_id" asserted),
ciphertext + legacy-plaintext reads, and the corrupt-string guard
(verified to fail via stash/run/pop when the guard is reverted).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230
AndresL230 merged commit c19a880 into mainAug 6, 2026
7 checks passed
@AndresL230
AndresL230 deleted the feat/522-c-521-quiz branch August 6, 2026 01:28
AndresL230 added a commit that referenced this pull request Aug 13, 2026
…L sweep, resume, paginated history (#542) (#550)
* feat(quiz): attempt lifecycle — mastery snapshot, derived status + TTL sweep, resume, paginated history (#542)
Workstream D of the pre-revamp quiz repair batch (epic #537):
D1 — submit persists mastery_before/mastery_after on the attempt row
(migration 20260813013547; plaintext analytics scalars per #521), so a
replayed or audited submit can reconstruct what the student saw.
D2 — status is DERIVED, never stored: completed_at → completed,
abandoned_at → abandoned, else in_progress; an in-progress row past
QUIZ_ATTEMPT_ABANDON_TTL_HOURS (24h, documented) reads as abandoned even
before the lazy per-user sweep stamps abandoned_at on the read paths
(no scheduler needed; conditional-update filters arbitrate).
GET /api/quiz/attempts/{id} returns resume state: questions WITHOUT the
answer key plus the responses recorded through /answer.
D3 — quizzes_completed counts completed attempts only; generate writes
the attempt row before the student answers anything, so the unfiltered
count let "generate and close the tab" advance quizzes_10. Blast radius
(measured on staging 2026-08-12): 1 user, 2 attempts, 0 completed,
badge never granted — nobody loses anything; prod recheck noted in the
PR. No revocation in any case.
D4 — GET /api/quiz/attempts: paginated history (concept, course, score,
total, difficulty, mastery delta, dates) — the plaintext scalars kept
for exactly this purpose in #521/#527 finally have a reader.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(quiz): real-DB coverage for the attempt lifecycle columns and sweep (#542)
The mastery snapshot and abandonment sweep are pure DB behaviour: a
mocked table() accepts columns the migration never added (#265 drift
class), and the conditional-update filters that decide WHICH attempts get
swept only mean something against Postgres. Asserts the columns
round-trip and that the sweep touches the stale in-progress attempt while
leaving a fresh one and a completed one alone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(quiz): address #550 review — close the resume answer leak, make abandoned bite, harden history (#542)
Review findings (xhigh, 15 confirmed). The serious ones:
- The resume endpoint leaked the answer twice over: _strip_answer_key
dropped only per-option `correct` and shipped `explanation` (which
names the answer in prose), and being a DENYLIST it passed unknown
stored shapes straight through — the rich seed's legacy row exposes
its answer under `a` while getting an empty options list. It is an
ALLOWLIST now (id/question/concept_tested/difficulty + label/text),
and an unrecognised stored shape 409s QUIZ_ATTEMPT_NOT_RESUMABLE
rather than being projected at all.
- "Abandoned" was cosmetic: resume served the full question set and both
/answer and /submit accepted swept attempts, paying out mastery, XP and
achievements. Both write paths now 409 QUIZ_ATTEMPT_ABANDONED, and
resume returns no questions with resumable:false.
- quizzes_completed counted claimed-but-never-graded attempts, because
completed_at is stamped by the atomic claim BEFORE grading. It now also
requires a persisted score.
- The TTL keyed on created_at alone, so an attempt being actively
answered was swept. Status and sweep both consider the newest recorded
answer; the active attempt is exempted from the sweep.
- The stored mastery snapshot was submit's local prediction; it now
records what apply_graph_update actually wrote (it clamps and resolves
by concept name), so history can't show progression the graph refused.
- The sweep is a write on a GET: it now sends Prefer: return=minimal
(new db/connection.py option) instead of dragging every swept row —
encrypted blobs included — back on each history page load.
- history: offset clamped at the top (an unbounded value 500s as
bigint-out-of-range) and ordered created_at.desc,id.desc so rows
sharing a timestamp can't repeat or vanish across pages.
- _attempt_status parsed naive timestamps fine and then raised TypeError
comparing them to an aware cutoff, past the ValueError guard; a shared
_parse_ts assumes UTC for naive values.
- Migration DDL is idempotent (IF NOT EXISTS) per the repo rule, plus a
partial index for the sweep/history predicate.
Test gaps the review found: the history "no question payloads" assertion
was vacuous (it now asserts the SELECTED COLUMNS), and neither new GET
had ownership coverage — the hermetic lane structurally cannot provide it
(require_self is stubbed there), so the IDOR negatives live in the
integration lane with real sessions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

Quiz performance data stored in plaintext (questions_json, answers_json, quiz_context)

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(quiz): encrypt quiz performance JSON at rest (#521) - #527

Merged
AndresL230 merged 4 commits into
mainfrom
feat/522-c-521-quiz
Aug 6, 2026
Merged

feat(quiz): encrypt quiz performance JSON at rest (#521)#527
AndresL230 merged 4 commits into
mainfrom
feat/522-c-521-quiz

Conversation

@AndresL230

Copy link
Copy Markdown
Collaborator

Closes#521.

  • quiz_attempts.questions_json/answers_json + quiz_context.context_json via encrypt_json, matching sessions.summary_json
  • decrypt at all four readers (submit scoring, quiz_context_service, quiz_history agent tool, course_context) — always before prompts and before the lru cache
  • scalars (score/total/difficulty/completed_at) untouched; analytics unaffected
  • new decrypt_json_column guard: legacy plaintext JSONB rows keep working pre-backfill (tested)
  • backfill runners, encrypted seed, roundtrip tests, ciphertext-oracle entries

Stacked on #520's PR.

🤖 Generated with Claude Code

@coderabbitai

coderabbitaiBot commented Aug 5, 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:55 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

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: 14afac61-08f8-4a7c-a15e-08a9c9ba25b0

📥 Commits

Reviewing files that changed from the base of the PR and between 01fea19 and 3e78b3e.

📒 Files selected for processing (14)
  • CLAUDE.md
  • backend/agents/tools/quiz_history.py
  • backend/db/backfill_encryption.py
  • backend/db/seed_local_rich.py
  • backend/e2e_oracles/gather.py
  • backend/routes/quiz.py
  • backend/services/course_context_service.py
  • backend/services/encryption.py
  • backend/services/quiz_context_service.py
  • backend/tests/integration/test_encryption_roundtrip.py
  • backend/tests/test_encryption_json_column.py
  • backend/tests/test_quiz_context_service.py
  • backend/tests/test_quiz_history_tool.py
  • backend/tests/test_quiz_routes.py

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.

@supabase

supabaseBot commented Aug 5, 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 ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 5, 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-staging1bc62c2Commit Preview URL

Branch Preview URL
Aug 06 2026, 01:16 AM

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 issue:

  1. get_quiz_context returns decrypt_json_column(rows[0]["context_json"]) with no guard, and submit_quiz calls it after the atomic completed_at claim, the mastery write, and the score/answers_json update. An undecryptable quiz_context row (corruption, key mismatch) turns an already-scored submission into an unhandled 500 — the client never receives the score, quiz.completed never logs, and a retry 409s forever. Every other decrypt_json/decrypt_json_column call site degrades instead (the convention is documented at routes/documents.py:250-253, and this PR's own quiz_history.py and course_context_service.py sites are wrapped) — this is the one site that skips it, in the worst place to skip it.

)
ifrows:
returndecrypt_json_column(rows[0]["context_json"])
returnNone

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@AndresL230
AndresL230force-pushed the feat/522-b-520-feedback branch from 3052b3a to 1e36394CompareAugust 6, 2026 01:21
@AndresL230
AndresL230 changed the base branch from feat/522-b-520-feedback to mainAugust 6, 2026 01:25
AndresL230and others added 4 commits August 5, 2026 21:25
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#521)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rypt; boundary tests (#521)
get_quiz_context ran unguarded post-commit in submit_quiz — a corrupt
context_json 500ed a request whose mastery/score writes already landed,
and every retry then hit the completed_at 409 with no recovery path.
Wrap the decrypt in try/except (mirrors documents.py's degrade
convention) and log + return None instead of raising.
Also reorders submit_quiz so require_self(user_id, request) runs before
decrypt_json_column(questions_json) — auth before decrypt work.
Adds tests/test_quiz_context_service.py (FakeTable pattern) covering
the ciphertext upsert (on_conflict="user_id,concept_node_id" asserted),
ciphertext + legacy-plaintext reads, and the corrupt-string guard
(verified to fail via stash/run/pop when the guard is reverted).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230
AndresL230 merged commit c19a880 into mainAug 6, 2026
7 checks passed
@AndresL230
AndresL230 deleted the feat/522-c-521-quiz branch August 6, 2026 01:28
AndresL230 added a commit that referenced this pull request Aug 13, 2026
…L sweep, resume, paginated history (#542) (#550)
* feat(quiz): attempt lifecycle — mastery snapshot, derived status + TTL sweep, resume, paginated history (#542)
Workstream D of the pre-revamp quiz repair batch (epic #537):
D1 — submit persists mastery_before/mastery_after on the attempt row
(migration 20260813013547; plaintext analytics scalars per #521), so a
replayed or audited submit can reconstruct what the student saw.
D2 — status is DERIVED, never stored: completed_at → completed,
abandoned_at → abandoned, else in_progress; an in-progress row past
QUIZ_ATTEMPT_ABANDON_TTL_HOURS (24h, documented) reads as abandoned even
before the lazy per-user sweep stamps abandoned_at on the read paths
(no scheduler needed; conditional-update filters arbitrate).
GET /api/quiz/attempts/{id} returns resume state: questions WITHOUT the
answer key plus the responses recorded through /answer.
D3 — quizzes_completed counts completed attempts only; generate writes
the attempt row before the student answers anything, so the unfiltered
count let "generate and close the tab" advance quizzes_10. Blast radius
(measured on staging 2026-08-12): 1 user, 2 attempts, 0 completed,
badge never granted — nobody loses anything; prod recheck noted in the
PR. No revocation in any case.
D4 — GET /api/quiz/attempts: paginated history (concept, course, score,
total, difficulty, mastery delta, dates) — the plaintext scalars kept
for exactly this purpose in #521/#527 finally have a reader.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(quiz): real-DB coverage for the attempt lifecycle columns and sweep (#542)
The mastery snapshot and abandonment sweep are pure DB behaviour: a
mocked table() accepts columns the migration never added (#265 drift
class), and the conditional-update filters that decide WHICH attempts get
swept only mean something against Postgres. Asserts the columns
round-trip and that the sweep touches the stale in-progress attempt while
leaving a fresh one and a completed one alone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(quiz): address #550 review — close the resume answer leak, make abandoned bite, harden history (#542)
Review findings (xhigh, 15 confirmed). The serious ones:
- The resume endpoint leaked the answer twice over: _strip_answer_key
dropped only per-option `correct` and shipped `explanation` (which
names the answer in prose), and being a DENYLIST it passed unknown
stored shapes straight through — the rich seed's legacy row exposes
its answer under `a` while getting an empty options list. It is an
ALLOWLIST now (id/question/concept_tested/difficulty + label/text),
and an unrecognised stored shape 409s QUIZ_ATTEMPT_NOT_RESUMABLE
rather than being projected at all.
- "Abandoned" was cosmetic: resume served the full question set and both
/answer and /submit accepted swept attempts, paying out mastery, XP and
achievements. Both write paths now 409 QUIZ_ATTEMPT_ABANDONED, and
resume returns no questions with resumable:false.
- quizzes_completed counted claimed-but-never-graded attempts, because
completed_at is stamped by the atomic claim BEFORE grading. It now also
requires a persisted score.
- The TTL keyed on created_at alone, so an attempt being actively
answered was swept. Status and sweep both consider the newest recorded
answer; the active attempt is exempted from the sweep.
- The stored mastery snapshot was submit's local prediction; it now
records what apply_graph_update actually wrote (it clamps and resolves
by concept name), so history can't show progression the graph refused.
- The sweep is a write on a GET: it now sends Prefer: return=minimal
(new db/connection.py option) instead of dragging every swept row —
encrypted blobs included — back on each history page load.
- history: offset clamped at the top (an unbounded value 500s as
bigint-out-of-range) and ordered created_at.desc,id.desc so rows
sharing a timestamp can't repeat or vanish across pages.
- _attempt_status parsed naive timestamps fine and then raised TypeError
comparing them to an aware cutoff, past the ValueError guard; a shared
_parse_ts assumes UTC for naive values.
- Migration DDL is idempotent (IF NOT EXISTS) per the repo rule, plus a
partial index for the sweep/history predicate.
Test gaps the review found: the history "no question payloads" assertion
was vacuous (it now asserts the SELECTED COLUMNS), and neither new GET
had ownership coverage — the hermetic lane structurally cannot provide it
(require_self is stubbed there), so the IDOR negatives live in the
integration lane with real sessions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

Quiz performance data stored in plaintext (questions_json, answers_json, quiz_context)

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(quiz): encrypt quiz performance JSON at rest (#521) - #527

Merged
AndresL230 merged 4 commits into
mainfrom
feat/522-c-521-quiz
Aug 6, 2026
Merged

feat(quiz): encrypt quiz performance JSON at rest (#521)#527
AndresL230 merged 4 commits into
mainfrom
feat/522-c-521-quiz

Conversation

@AndresL230

Copy link
Copy Markdown
Collaborator

Closes#521.

  • quiz_attempts.questions_json/answers_json + quiz_context.context_json via encrypt_json, matching sessions.summary_json
  • decrypt at all four readers (submit scoring, quiz_context_service, quiz_history agent tool, course_context) — always before prompts and before the lru cache
  • scalars (score/total/difficulty/completed_at) untouched; analytics unaffected
  • new decrypt_json_column guard: legacy plaintext JSONB rows keep working pre-backfill (tested)
  • backfill runners, encrypted seed, roundtrip tests, ciphertext-oracle entries

Stacked on #520's PR.

🤖 Generated with Claude Code

@coderabbitai

coderabbitaiBot commented Aug 5, 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:55 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

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: 14afac61-08f8-4a7c-a15e-08a9c9ba25b0

📥 Commits

Reviewing files that changed from the base of the PR and between 01fea19 and 3e78b3e.

📒 Files selected for processing (14)
  • CLAUDE.md
  • backend/agents/tools/quiz_history.py
  • backend/db/backfill_encryption.py
  • backend/db/seed_local_rich.py
  • backend/e2e_oracles/gather.py
  • backend/routes/quiz.py
  • backend/services/course_context_service.py
  • backend/services/encryption.py
  • backend/services/quiz_context_service.py
  • backend/tests/integration/test_encryption_roundtrip.py
  • backend/tests/test_encryption_json_column.py
  • backend/tests/test_quiz_context_service.py
  • backend/tests/test_quiz_history_tool.py
  • backend/tests/test_quiz_routes.py

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.

@supabase

supabaseBot commented Aug 5, 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 ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 5, 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-staging1bc62c2Commit Preview URL

Branch Preview URL
Aug 06 2026, 01:16 AM

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 issue:

  1. get_quiz_context returns decrypt_json_column(rows[0]["context_json"]) with no guard, and submit_quiz calls it after the atomic completed_at claim, the mastery write, and the score/answers_json update. An undecryptable quiz_context row (corruption, key mismatch) turns an already-scored submission into an unhandled 500 — the client never receives the score, quiz.completed never logs, and a retry 409s forever. Every other decrypt_json/decrypt_json_column call site degrades instead (the convention is documented at routes/documents.py:250-253, and this PR's own quiz_history.py and course_context_service.py sites are wrapped) — this is the one site that skips it, in the worst place to skip it.

)
ifrows:
returndecrypt_json_column(rows[0]["context_json"])
returnNone

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@AndresL230
AndresL230force-pushed the feat/522-b-520-feedback branch from 3052b3a to 1e36394CompareAugust 6, 2026 01:21
@AndresL230
AndresL230 changed the base branch from feat/522-b-520-feedback to mainAugust 6, 2026 01:25
AndresL230and others added 4 commits August 5, 2026 21:25
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#521)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rypt; boundary tests (#521)
get_quiz_context ran unguarded post-commit in submit_quiz — a corrupt
context_json 500ed a request whose mastery/score writes already landed,
and every retry then hit the completed_at 409 with no recovery path.
Wrap the decrypt in try/except (mirrors documents.py's degrade
convention) and log + return None instead of raising.
Also reorders submit_quiz so require_self(user_id, request) runs before
decrypt_json_column(questions_json) — auth before decrypt work.
Adds tests/test_quiz_context_service.py (FakeTable pattern) covering
the ciphertext upsert (on_conflict="user_id,concept_node_id" asserted),
ciphertext + legacy-plaintext reads, and the corrupt-string guard
(verified to fail via stash/run/pop when the guard is reverted).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230
AndresL230 merged commit c19a880 into mainAug 6, 2026
7 checks passed
@AndresL230
AndresL230 deleted the feat/522-c-521-quiz branch August 6, 2026 01:28
AndresL230 added a commit that referenced this pull request Aug 13, 2026
…L sweep, resume, paginated history (#542) (#550)
* feat(quiz): attempt lifecycle — mastery snapshot, derived status + TTL sweep, resume, paginated history (#542)
Workstream D of the pre-revamp quiz repair batch (epic #537):
D1 — submit persists mastery_before/mastery_after on the attempt row
(migration 20260813013547; plaintext analytics scalars per #521), so a
replayed or audited submit can reconstruct what the student saw.
D2 — status is DERIVED, never stored: completed_at → completed,
abandoned_at → abandoned, else in_progress; an in-progress row past
QUIZ_ATTEMPT_ABANDON_TTL_HOURS (24h, documented) reads as abandoned even
before the lazy per-user sweep stamps abandoned_at on the read paths
(no scheduler needed; conditional-update filters arbitrate).
GET /api/quiz/attempts/{id} returns resume state: questions WITHOUT the
answer key plus the responses recorded through /answer.
D3 — quizzes_completed counts completed attempts only; generate writes
the attempt row before the student answers anything, so the unfiltered
count let "generate and close the tab" advance quizzes_10. Blast radius
(measured on staging 2026-08-12): 1 user, 2 attempts, 0 completed,
badge never granted — nobody loses anything; prod recheck noted in the
PR. No revocation in any case.
D4 — GET /api/quiz/attempts: paginated history (concept, course, score,
total, difficulty, mastery delta, dates) — the plaintext scalars kept
for exactly this purpose in #521/#527 finally have a reader.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(quiz): real-DB coverage for the attempt lifecycle columns and sweep (#542)
The mastery snapshot and abandonment sweep are pure DB behaviour: a
mocked table() accepts columns the migration never added (#265 drift
class), and the conditional-update filters that decide WHICH attempts get
swept only mean something against Postgres. Asserts the columns
round-trip and that the sweep touches the stale in-progress attempt while
leaving a fresh one and a completed one alone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(quiz): address #550 review — close the resume answer leak, make abandoned bite, harden history (#542)
Review findings (xhigh, 15 confirmed). The serious ones:
- The resume endpoint leaked the answer twice over: _strip_answer_key
dropped only per-option `correct` and shipped `explanation` (which
names the answer in prose), and being a DENYLIST it passed unknown
stored shapes straight through — the rich seed's legacy row exposes
its answer under `a` while getting an empty options list. It is an
ALLOWLIST now (id/question/concept_tested/difficulty + label/text),
and an unrecognised stored shape 409s QUIZ_ATTEMPT_NOT_RESUMABLE
rather than being projected at all.
- "Abandoned" was cosmetic: resume served the full question set and both
/answer and /submit accepted swept attempts, paying out mastery, XP and
achievements. Both write paths now 409 QUIZ_ATTEMPT_ABANDONED, and
resume returns no questions with resumable:false.
- quizzes_completed counted claimed-but-never-graded attempts, because
completed_at is stamped by the atomic claim BEFORE grading. It now also
requires a persisted score.
- The TTL keyed on created_at alone, so an attempt being actively
answered was swept. Status and sweep both consider the newest recorded
answer; the active attempt is exempted from the sweep.
- The stored mastery snapshot was submit's local prediction; it now
records what apply_graph_update actually wrote (it clamps and resolves
by concept name), so history can't show progression the graph refused.
- The sweep is a write on a GET: it now sends Prefer: return=minimal
(new db/connection.py option) instead of dragging every swept row —
encrypted blobs included — back on each history page load.
- history: offset clamped at the top (an unbounded value 500s as
bigint-out-of-range) and ordered created_at.desc,id.desc so rows
sharing a timestamp can't repeat or vanish across pages.
- _attempt_status parsed naive timestamps fine and then raised TypeError
comparing them to an aware cutoff, past the ValueError guard; a shared
_parse_ts assumes UTC for naive values.
- Migration DDL is idempotent (IF NOT EXISTS) per the repo rule, plus a
partial index for the sweep/history predicate.
Test gaps the review found: the history "no question payloads" assertion
was vacuous (it now asserts the SELECTED COLUMNS), and neither new GET
had ownership coverage — the hermetic lane structurally cannot provide it
(require_self is stubbed there), so the IDOR negatives live in the
integration lane with real sessions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

Quiz performance data stored in plaintext (questions_json, answers_json, quiz_context)

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat(quiz): encrypt quiz performance JSON at rest (#521) - #527

Merged
AndresL230 merged 4 commits into
mainfrom
feat/522-c-521-quiz
Aug 6, 2026
Merged

feat(quiz): encrypt quiz performance JSON at rest (#521)#527
AndresL230 merged 4 commits into
mainfrom
feat/522-c-521-quiz

Conversation

@AndresL230

Copy link
Copy Markdown
Collaborator

Closes#521.

  • quiz_attempts.questions_json/answers_json + quiz_context.context_json via encrypt_json, matching sessions.summary_json
  • decrypt at all four readers (submit scoring, quiz_context_service, quiz_history agent tool, course_context) — always before prompts and before the lru cache
  • scalars (score/total/difficulty/completed_at) untouched; analytics unaffected
  • new decrypt_json_column guard: legacy plaintext JSONB rows keep working pre-backfill (tested)
  • backfill runners, encrypted seed, roundtrip tests, ciphertext-oracle entries

Stacked on #520's PR.

🤖 Generated with Claude Code

@coderabbitai

coderabbitaiBot commented Aug 5, 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:55 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

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: 14afac61-08f8-4a7c-a15e-08a9c9ba25b0

📥 Commits

Reviewing files that changed from the base of the PR and between 01fea19 and 3e78b3e.

📒 Files selected for processing (14)
  • CLAUDE.md
  • backend/agents/tools/quiz_history.py
  • backend/db/backfill_encryption.py
  • backend/db/seed_local_rich.py
  • backend/e2e_oracles/gather.py
  • backend/routes/quiz.py
  • backend/services/course_context_service.py
  • backend/services/encryption.py
  • backend/services/quiz_context_service.py
  • backend/tests/integration/test_encryption_roundtrip.py
  • backend/tests/test_encryption_json_column.py
  • backend/tests/test_quiz_context_service.py
  • backend/tests/test_quiz_history_tool.py
  • backend/tests/test_quiz_routes.py

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.

@supabase

supabaseBot commented Aug 5, 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 ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 5, 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-staging1bc62c2Commit Preview URL

Branch Preview URL
Aug 06 2026, 01:16 AM

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 issue:

  1. get_quiz_context returns decrypt_json_column(rows[0]["context_json"]) with no guard, and submit_quiz calls it after the atomic completed_at claim, the mastery write, and the score/answers_json update. An undecryptable quiz_context row (corruption, key mismatch) turns an already-scored submission into an unhandled 500 — the client never receives the score, quiz.completed never logs, and a retry 409s forever. Every other decrypt_json/decrypt_json_column call site degrades instead (the convention is documented at routes/documents.py:250-253, and this PR's own quiz_history.py and course_context_service.py sites are wrapped) — this is the one site that skips it, in the worst place to skip it.

)
ifrows:
returndecrypt_json_column(rows[0]["context_json"])
returnNone

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@AndresL230
AndresL230force-pushed the feat/522-b-520-feedback branch from 3052b3a to 1e36394CompareAugust 6, 2026 01:21
@AndresL230
AndresL230 changed the base branch from feat/522-b-520-feedback to mainAugust 6, 2026 01:25
AndresL230and others added 4 commits August 5, 2026 21:25
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#521)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rypt; boundary tests (#521)
get_quiz_context ran unguarded post-commit in submit_quiz — a corrupt
context_json 500ed a request whose mastery/score writes already landed,
and every retry then hit the completed_at 409 with no recovery path.
Wrap the decrypt in try/except (mirrors documents.py's degrade
convention) and log + return None instead of raising.
Also reorders submit_quiz so require_self(user_id, request) runs before
decrypt_json_column(questions_json) — auth before decrypt work.
Adds tests/test_quiz_context_service.py (FakeTable pattern) covering
the ciphertext upsert (on_conflict="user_id,concept_node_id" asserted),
ciphertext + legacy-plaintext reads, and the corrupt-string guard
(verified to fail via stash/run/pop when the guard is reverted).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230
AndresL230 merged commit c19a880 into mainAug 6, 2026
7 checks passed
@AndresL230
AndresL230 deleted the feat/522-c-521-quiz branch August 6, 2026 01:28
AndresL230 added a commit that referenced this pull request Aug 13, 2026
…L sweep, resume, paginated history (#542) (#550)
* feat(quiz): attempt lifecycle — mastery snapshot, derived status + TTL sweep, resume, paginated history (#542)
Workstream D of the pre-revamp quiz repair batch (epic #537):
D1 — submit persists mastery_before/mastery_after on the attempt row
(migration 20260813013547; plaintext analytics scalars per #521), so a
replayed or audited submit can reconstruct what the student saw.
D2 — status is DERIVED, never stored: completed_at → completed,
abandoned_at → abandoned, else in_progress; an in-progress row past
QUIZ_ATTEMPT_ABANDON_TTL_HOURS (24h, documented) reads as abandoned even
before the lazy per-user sweep stamps abandoned_at on the read paths
(no scheduler needed; conditional-update filters arbitrate).
GET /api/quiz/attempts/{id} returns resume state: questions WITHOUT the
answer key plus the responses recorded through /answer.
D3 — quizzes_completed counts completed attempts only; generate writes
the attempt row before the student answers anything, so the unfiltered
count let "generate and close the tab" advance quizzes_10. Blast radius
(measured on staging 2026-08-12): 1 user, 2 attempts, 0 completed,
badge never granted — nobody loses anything; prod recheck noted in the
PR. No revocation in any case.
D4 — GET /api/quiz/attempts: paginated history (concept, course, score,
total, difficulty, mastery delta, dates) — the plaintext scalars kept
for exactly this purpose in #521/#527 finally have a reader.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(quiz): real-DB coverage for the attempt lifecycle columns and sweep (#542)
The mastery snapshot and abandonment sweep are pure DB behaviour: a
mocked table() accepts columns the migration never added (#265 drift
class), and the conditional-update filters that decide WHICH attempts get
swept only mean something against Postgres. Asserts the columns
round-trip and that the sweep touches the stale in-progress attempt while
leaving a fresh one and a completed one alone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(quiz): address #550 review — close the resume answer leak, make abandoned bite, harden history (#542)
Review findings (xhigh, 15 confirmed). The serious ones:
- The resume endpoint leaked the answer twice over: _strip_answer_key
dropped only per-option `correct` and shipped `explanation` (which
names the answer in prose), and being a DENYLIST it passed unknown
stored shapes straight through — the rich seed's legacy row exposes
its answer under `a` while getting an empty options list. It is an
ALLOWLIST now (id/question/concept_tested/difficulty + label/text),
and an unrecognised stored shape 409s QUIZ_ATTEMPT_NOT_RESUMABLE
rather than being projected at all.
- "Abandoned" was cosmetic: resume served the full question set and both
/answer and /submit accepted swept attempts, paying out mastery, XP and
achievements. Both write paths now 409 QUIZ_ATTEMPT_ABANDONED, and
resume returns no questions with resumable:false.
- quizzes_completed counted claimed-but-never-graded attempts, because
completed_at is stamped by the atomic claim BEFORE grading. It now also
requires a persisted score.
- The TTL keyed on created_at alone, so an attempt being actively
answered was swept. Status and sweep both consider the newest recorded
answer; the active attempt is exempted from the sweep.
- The stored mastery snapshot was submit's local prediction; it now
records what apply_graph_update actually wrote (it clamps and resolves
by concept name), so history can't show progression the graph refused.
- The sweep is a write on a GET: it now sends Prefer: return=minimal
(new db/connection.py option) instead of dragging every swept row —
encrypted blobs included — back on each history page load.
- history: offset clamped at the top (an unbounded value 500s as
bigint-out-of-range) and ordered created_at.desc,id.desc so rows
sharing a timestamp can't repeat or vanish across pages.
- _attempt_status parsed naive timestamps fine and then raised TypeError
comparing them to an aware cutoff, past the ValueError guard; a shared
_parse_ts assumes UTC for naive values.
- Migration DDL is idempotent (IF NOT EXISTS) per the repo rule, plus a
partial index for the sweep/history predicate.
Test gaps the review found: the history "no question payloads" assertion
was vacuous (it now asserts the SELECTED COLUMNS), and neither new GET
had ownership coverage — the hermetic lane structurally cannot provide it
(require_self is stubbed there), so the IDOR negatives live in the
integration lane with real sessions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

Quiz performance data stored in plaintext (questions_json, answers_json, quiz_context)

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(quiz): encrypt quiz performance JSON at rest (#521) - #527

Merged
AndresL230 merged 4 commits into
mainfrom
feat/522-c-521-quiz
Aug 6, 2026
Merged

feat(quiz): encrypt quiz performance JSON at rest (#521)#527
AndresL230 merged 4 commits into
mainfrom
feat/522-c-521-quiz

Conversation

@AndresL230

Copy link
Copy Markdown
Collaborator

Closes#521.

  • quiz_attempts.questions_json/answers_json + quiz_context.context_json via encrypt_json, matching sessions.summary_json
  • decrypt at all four readers (submit scoring, quiz_context_service, quiz_history agent tool, course_context) — always before prompts and before the lru cache
  • scalars (score/total/difficulty/completed_at) untouched; analytics unaffected
  • new decrypt_json_column guard: legacy plaintext JSONB rows keep working pre-backfill (tested)
  • backfill runners, encrypted seed, roundtrip tests, ciphertext-oracle entries

Stacked on #520's PR.

🤖 Generated with Claude Code

@coderabbitai

coderabbitaiBot commented Aug 5, 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:55 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

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: 14afac61-08f8-4a7c-a15e-08a9c9ba25b0

📥 Commits

Reviewing files that changed from the base of the PR and between 01fea19 and 3e78b3e.

📒 Files selected for processing (14)
  • CLAUDE.md
  • backend/agents/tools/quiz_history.py
  • backend/db/backfill_encryption.py
  • backend/db/seed_local_rich.py
  • backend/e2e_oracles/gather.py
  • backend/routes/quiz.py
  • backend/services/course_context_service.py
  • backend/services/encryption.py
  • backend/services/quiz_context_service.py
  • backend/tests/integration/test_encryption_roundtrip.py
  • backend/tests/test_encryption_json_column.py
  • backend/tests/test_quiz_context_service.py
  • backend/tests/test_quiz_history_tool.py
  • backend/tests/test_quiz_routes.py

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.

@supabase

supabaseBot commented Aug 5, 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 ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 5, 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-staging1bc62c2Commit Preview URL

Branch Preview URL
Aug 06 2026, 01:16 AM

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 issue:

  1. get_quiz_context returns decrypt_json_column(rows[0]["context_json"]) with no guard, and submit_quiz calls it after the atomic completed_at claim, the mastery write, and the score/answers_json update. An undecryptable quiz_context row (corruption, key mismatch) turns an already-scored submission into an unhandled 500 — the client never receives the score, quiz.completed never logs, and a retry 409s forever. Every other decrypt_json/decrypt_json_column call site degrades instead (the convention is documented at routes/documents.py:250-253, and this PR's own quiz_history.py and course_context_service.py sites are wrapped) — this is the one site that skips it, in the worst place to skip it.

)
ifrows:
returndecrypt_json_column(rows[0]["context_json"])
returnNone

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@AndresL230
AndresL230force-pushed the feat/522-b-520-feedback branch from 3052b3a to 1e36394CompareAugust 6, 2026 01:21
@AndresL230
AndresL230 changed the base branch from feat/522-b-520-feedback to mainAugust 6, 2026 01:25
AndresL230and others added 4 commits August 5, 2026 21:25
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#521)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rypt; boundary tests (#521)
get_quiz_context ran unguarded post-commit in submit_quiz — a corrupt
context_json 500ed a request whose mastery/score writes already landed,
and every retry then hit the completed_at 409 with no recovery path.
Wrap the decrypt in try/except (mirrors documents.py's degrade
convention) and log + return None instead of raising.
Also reorders submit_quiz so require_self(user_id, request) runs before
decrypt_json_column(questions_json) — auth before decrypt work.
Adds tests/test_quiz_context_service.py (FakeTable pattern) covering
the ciphertext upsert (on_conflict="user_id,concept_node_id" asserted),
ciphertext + legacy-plaintext reads, and the corrupt-string guard
(verified to fail via stash/run/pop when the guard is reverted).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230
AndresL230 merged commit c19a880 into mainAug 6, 2026
7 checks passed
@AndresL230
AndresL230 deleted the feat/522-c-521-quiz branch August 6, 2026 01:28
AndresL230 added a commit that referenced this pull request Aug 13, 2026
…L sweep, resume, paginated history (#542) (#550)
* feat(quiz): attempt lifecycle — mastery snapshot, derived status + TTL sweep, resume, paginated history (#542)
Workstream D of the pre-revamp quiz repair batch (epic #537):
D1 — submit persists mastery_before/mastery_after on the attempt row
(migration 20260813013547; plaintext analytics scalars per #521), so a
replayed or audited submit can reconstruct what the student saw.
D2 — status is DERIVED, never stored: completed_at → completed,
abandoned_at → abandoned, else in_progress; an in-progress row past
QUIZ_ATTEMPT_ABANDON_TTL_HOURS (24h, documented) reads as abandoned even
before the lazy per-user sweep stamps abandoned_at on the read paths
(no scheduler needed; conditional-update filters arbitrate).
GET /api/quiz/attempts/{id} returns resume state: questions WITHOUT the
answer key plus the responses recorded through /answer.
D3 — quizzes_completed counts completed attempts only; generate writes
the attempt row before the student answers anything, so the unfiltered
count let "generate and close the tab" advance quizzes_10. Blast radius
(measured on staging 2026-08-12): 1 user, 2 attempts, 0 completed,
badge never granted — nobody loses anything; prod recheck noted in the
PR. No revocation in any case.
D4 — GET /api/quiz/attempts: paginated history (concept, course, score,
total, difficulty, mastery delta, dates) — the plaintext scalars kept
for exactly this purpose in #521/#527 finally have a reader.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(quiz): real-DB coverage for the attempt lifecycle columns and sweep (#542)
The mastery snapshot and abandonment sweep are pure DB behaviour: a
mocked table() accepts columns the migration never added (#265 drift
class), and the conditional-update filters that decide WHICH attempts get
swept only mean something against Postgres. Asserts the columns
round-trip and that the sweep touches the stale in-progress attempt while
leaving a fresh one and a completed one alone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(quiz): address #550 review — close the resume answer leak, make abandoned bite, harden history (#542)
Review findings (xhigh, 15 confirmed). The serious ones:
- The resume endpoint leaked the answer twice over: _strip_answer_key
dropped only per-option `correct` and shipped `explanation` (which
names the answer in prose), and being a DENYLIST it passed unknown
stored shapes straight through — the rich seed's legacy row exposes
its answer under `a` while getting an empty options list. It is an
ALLOWLIST now (id/question/concept_tested/difficulty + label/text),
and an unrecognised stored shape 409s QUIZ_ATTEMPT_NOT_RESUMABLE
rather than being projected at all.
- "Abandoned" was cosmetic: resume served the full question set and both
/answer and /submit accepted swept attempts, paying out mastery, XP and
achievements. Both write paths now 409 QUIZ_ATTEMPT_ABANDONED, and
resume returns no questions with resumable:false.
- quizzes_completed counted claimed-but-never-graded attempts, because
completed_at is stamped by the atomic claim BEFORE grading. It now also
requires a persisted score.
- The TTL keyed on created_at alone, so an attempt being actively
answered was swept. Status and sweep both consider the newest recorded
answer; the active attempt is exempted from the sweep.
- The stored mastery snapshot was submit's local prediction; it now
records what apply_graph_update actually wrote (it clamps and resolves
by concept name), so history can't show progression the graph refused.
- The sweep is a write on a GET: it now sends Prefer: return=minimal
(new db/connection.py option) instead of dragging every swept row —
encrypted blobs included — back on each history page load.
- history: offset clamped at the top (an unbounded value 500s as
bigint-out-of-range) and ordered created_at.desc,id.desc so rows
sharing a timestamp can't repeat or vanish across pages.
- _attempt_status parsed naive timestamps fine and then raised TypeError
comparing them to an aware cutoff, past the ValueError guard; a shared
_parse_ts assumes UTC for naive values.
- Migration DDL is idempotent (IF NOT EXISTS) per the repo rule, plus a
partial index for the sweep/history predicate.
Test gaps the review found: the history "no question payloads" assertion
was vacuous (it now asserts the SELECTED COLUMNS), and neither new GET
had ownership coverage — the hermetic lane structurally cannot provide it
(require_self is stubbed there), so the IDOR negatives live in the
integration lane with real sessions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

Quiz performance data stored in plaintext (questions_json, answers_json, quiz_context)

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(quiz): encrypt quiz performance JSON at rest (#521) - #527

Merged
AndresL230 merged 4 commits into
mainfrom
feat/522-c-521-quiz
Aug 6, 2026
Merged

feat(quiz): encrypt quiz performance JSON at rest (#521)#527
AndresL230 merged 4 commits into
mainfrom
feat/522-c-521-quiz

Conversation

@AndresL230

Copy link
Copy Markdown
Collaborator

Closes#521.

  • quiz_attempts.questions_json/answers_json + quiz_context.context_json via encrypt_json, matching sessions.summary_json
  • decrypt at all four readers (submit scoring, quiz_context_service, quiz_history agent tool, course_context) — always before prompts and before the lru cache
  • scalars (score/total/difficulty/completed_at) untouched; analytics unaffected
  • new decrypt_json_column guard: legacy plaintext JSONB rows keep working pre-backfill (tested)
  • backfill runners, encrypted seed, roundtrip tests, ciphertext-oracle entries

Stacked on #520's PR.

🤖 Generated with Claude Code

@coderabbitai

coderabbitaiBot commented Aug 5, 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:55 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

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: 14afac61-08f8-4a7c-a15e-08a9c9ba25b0

📥 Commits

Reviewing files that changed from the base of the PR and between 01fea19 and 3e78b3e.

📒 Files selected for processing (14)
  • CLAUDE.md
  • backend/agents/tools/quiz_history.py
  • backend/db/backfill_encryption.py
  • backend/db/seed_local_rich.py
  • backend/e2e_oracles/gather.py
  • backend/routes/quiz.py
  • backend/services/course_context_service.py
  • backend/services/encryption.py
  • backend/services/quiz_context_service.py
  • backend/tests/integration/test_encryption_roundtrip.py
  • backend/tests/test_encryption_json_column.py
  • backend/tests/test_quiz_context_service.py
  • backend/tests/test_quiz_history_tool.py
  • backend/tests/test_quiz_routes.py

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.

@supabase

supabaseBot commented Aug 5, 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 ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 5, 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-staging1bc62c2Commit Preview URL

Branch Preview URL
Aug 06 2026, 01:16 AM

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 issue:

  1. get_quiz_context returns decrypt_json_column(rows[0]["context_json"]) with no guard, and submit_quiz calls it after the atomic completed_at claim, the mastery write, and the score/answers_json update. An undecryptable quiz_context row (corruption, key mismatch) turns an already-scored submission into an unhandled 500 — the client never receives the score, quiz.completed never logs, and a retry 409s forever. Every other decrypt_json/decrypt_json_column call site degrades instead (the convention is documented at routes/documents.py:250-253, and this PR's own quiz_history.py and course_context_service.py sites are wrapped) — this is the one site that skips it, in the worst place to skip it.

)
ifrows:
returndecrypt_json_column(rows[0]["context_json"])
returnNone

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@AndresL230
AndresL230force-pushed the feat/522-b-520-feedback branch from 3052b3a to 1e36394CompareAugust 6, 2026 01:21
@AndresL230
AndresL230 changed the base branch from feat/522-b-520-feedback to mainAugust 6, 2026 01:25
AndresL230and others added 4 commits August 5, 2026 21:25
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#521)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rypt; boundary tests (#521)
get_quiz_context ran unguarded post-commit in submit_quiz — a corrupt
context_json 500ed a request whose mastery/score writes already landed,
and every retry then hit the completed_at 409 with no recovery path.
Wrap the decrypt in try/except (mirrors documents.py's degrade
convention) and log + return None instead of raising.
Also reorders submit_quiz so require_self(user_id, request) runs before
decrypt_json_column(questions_json) — auth before decrypt work.
Adds tests/test_quiz_context_service.py (FakeTable pattern) covering
the ciphertext upsert (on_conflict="user_id,concept_node_id" asserted),
ciphertext + legacy-plaintext reads, and the corrupt-string guard
(verified to fail via stash/run/pop when the guard is reverted).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230
AndresL230 merged commit c19a880 into mainAug 6, 2026
7 checks passed
@AndresL230
AndresL230 deleted the feat/522-c-521-quiz branch August 6, 2026 01:28
AndresL230 added a commit that referenced this pull request Aug 13, 2026
…L sweep, resume, paginated history (#542) (#550)
* feat(quiz): attempt lifecycle — mastery snapshot, derived status + TTL sweep, resume, paginated history (#542)
Workstream D of the pre-revamp quiz repair batch (epic #537):
D1 — submit persists mastery_before/mastery_after on the attempt row
(migration 20260813013547; plaintext analytics scalars per #521), so a
replayed or audited submit can reconstruct what the student saw.
D2 — status is DERIVED, never stored: completed_at → completed,
abandoned_at → abandoned, else in_progress; an in-progress row past
QUIZ_ATTEMPT_ABANDON_TTL_HOURS (24h, documented) reads as abandoned even
before the lazy per-user sweep stamps abandoned_at on the read paths
(no scheduler needed; conditional-update filters arbitrate).
GET /api/quiz/attempts/{id} returns resume state: questions WITHOUT the
answer key plus the responses recorded through /answer.
D3 — quizzes_completed counts completed attempts only; generate writes
the attempt row before the student answers anything, so the unfiltered
count let "generate and close the tab" advance quizzes_10. Blast radius
(measured on staging 2026-08-12): 1 user, 2 attempts, 0 completed,
badge never granted — nobody loses anything; prod recheck noted in the
PR. No revocation in any case.
D4 — GET /api/quiz/attempts: paginated history (concept, course, score,
total, difficulty, mastery delta, dates) — the plaintext scalars kept
for exactly this purpose in #521/#527 finally have a reader.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(quiz): real-DB coverage for the attempt lifecycle columns and sweep (#542)
The mastery snapshot and abandonment sweep are pure DB behaviour: a
mocked table() accepts columns the migration never added (#265 drift
class), and the conditional-update filters that decide WHICH attempts get
swept only mean something against Postgres. Asserts the columns
round-trip and that the sweep touches the stale in-progress attempt while
leaving a fresh one and a completed one alone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(quiz): address #550 review — close the resume answer leak, make abandoned bite, harden history (#542)
Review findings (xhigh, 15 confirmed). The serious ones:
- The resume endpoint leaked the answer twice over: _strip_answer_key
dropped only per-option `correct` and shipped `explanation` (which
names the answer in prose), and being a DENYLIST it passed unknown
stored shapes straight through — the rich seed's legacy row exposes
its answer under `a` while getting an empty options list. It is an
ALLOWLIST now (id/question/concept_tested/difficulty + label/text),
and an unrecognised stored shape 409s QUIZ_ATTEMPT_NOT_RESUMABLE
rather than being projected at all.
- "Abandoned" was cosmetic: resume served the full question set and both
/answer and /submit accepted swept attempts, paying out mastery, XP and
achievements. Both write paths now 409 QUIZ_ATTEMPT_ABANDONED, and
resume returns no questions with resumable:false.
- quizzes_completed counted claimed-but-never-graded attempts, because
completed_at is stamped by the atomic claim BEFORE grading. It now also
requires a persisted score.
- The TTL keyed on created_at alone, so an attempt being actively
answered was swept. Status and sweep both consider the newest recorded
answer; the active attempt is exempted from the sweep.
- The stored mastery snapshot was submit's local prediction; it now
records what apply_graph_update actually wrote (it clamps and resolves
by concept name), so history can't show progression the graph refused.
- The sweep is a write on a GET: it now sends Prefer: return=minimal
(new db/connection.py option) instead of dragging every swept row —
encrypted blobs included — back on each history page load.
- history: offset clamped at the top (an unbounded value 500s as
bigint-out-of-range) and ordered created_at.desc,id.desc so rows
sharing a timestamp can't repeat or vanish across pages.
- _attempt_status parsed naive timestamps fine and then raised TypeError
comparing them to an aware cutoff, past the ValueError guard; a shared
_parse_ts assumes UTC for naive values.
- Migration DDL is idempotent (IF NOT EXISTS) per the repo rule, plus a
partial index for the sweep/history predicate.
Test gaps the review found: the history "no question payloads" assertion
was vacuous (it now asserts the SELECTED COLUMNS), and neither new GET
had ownership coverage — the hermetic lane structurally cannot provide it
(require_self is stubbed there), so the IDOR negatives live in the
integration lane with real sessions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

Quiz performance data stored in plaintext (questions_json, answers_json, quiz_context)

1 participant

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

feat(quiz): encrypt quiz performance JSON at rest (#521) - #527

Merged
AndresL230 merged 4 commits into
mainfrom
feat/522-c-521-quiz
Aug 6, 2026
Merged

feat(quiz): encrypt quiz performance JSON at rest (#521)#527
AndresL230 merged 4 commits into
mainfrom
feat/522-c-521-quiz

Conversation

@AndresL230

Copy link
Copy Markdown
Collaborator

Closes#521.

  • quiz_attempts.questions_json/answers_json + quiz_context.context_json via encrypt_json, matching sessions.summary_json
  • decrypt at all four readers (submit scoring, quiz_context_service, quiz_history agent tool, course_context) — always before prompts and before the lru cache
  • scalars (score/total/difficulty/completed_at) untouched; analytics unaffected
  • new decrypt_json_column guard: legacy plaintext JSONB rows keep working pre-backfill (tested)
  • backfill runners, encrypted seed, roundtrip tests, ciphertext-oracle entries

Stacked on #520's PR.

🤖 Generated with Claude Code

@coderabbitai

coderabbitaiBot commented Aug 5, 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:55 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

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: 14afac61-08f8-4a7c-a15e-08a9c9ba25b0

📥 Commits

Reviewing files that changed from the base of the PR and between 01fea19 and 3e78b3e.

📒 Files selected for processing (14)
  • CLAUDE.md
  • backend/agents/tools/quiz_history.py
  • backend/db/backfill_encryption.py
  • backend/db/seed_local_rich.py
  • backend/e2e_oracles/gather.py
  • backend/routes/quiz.py
  • backend/services/course_context_service.py
  • backend/services/encryption.py
  • backend/services/quiz_context_service.py
  • backend/tests/integration/test_encryption_roundtrip.py
  • backend/tests/test_encryption_json_column.py
  • backend/tests/test_quiz_context_service.py
  • backend/tests/test_quiz_history_tool.py
  • backend/tests/test_quiz_routes.py

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.

@supabase

supabaseBot commented Aug 5, 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 ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 5, 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-staging1bc62c2Commit Preview URL

Branch Preview URL
Aug 06 2026, 01:16 AM

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 issue:

  1. get_quiz_context returns decrypt_json_column(rows[0]["context_json"]) with no guard, and submit_quiz calls it after the atomic completed_at claim, the mastery write, and the score/answers_json update. An undecryptable quiz_context row (corruption, key mismatch) turns an already-scored submission into an unhandled 500 — the client never receives the score, quiz.completed never logs, and a retry 409s forever. Every other decrypt_json/decrypt_json_column call site degrades instead (the convention is documented at routes/documents.py:250-253, and this PR's own quiz_history.py and course_context_service.py sites are wrapped) — this is the one site that skips it, in the worst place to skip it.

)
ifrows:
returndecrypt_json_column(rows[0]["context_json"])
returnNone

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@AndresL230
AndresL230force-pushed the feat/522-b-520-feedback branch from 3052b3a to 1e36394CompareAugust 6, 2026 01:21
@AndresL230
AndresL230 changed the base branch from feat/522-b-520-feedback to mainAugust 6, 2026 01:25
AndresL230and others added 4 commits August 5, 2026 21:25
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#521)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rypt; boundary tests (#521)
get_quiz_context ran unguarded post-commit in submit_quiz — a corrupt
context_json 500ed a request whose mastery/score writes already landed,
and every retry then hit the completed_at 409 with no recovery path.
Wrap the decrypt in try/except (mirrors documents.py's degrade
convention) and log + return None instead of raising.
Also reorders submit_quiz so require_self(user_id, request) runs before
decrypt_json_column(questions_json) — auth before decrypt work.
Adds tests/test_quiz_context_service.py (FakeTable pattern) covering
the ciphertext upsert (on_conflict="user_id,concept_node_id" asserted),
ciphertext + legacy-plaintext reads, and the corrupt-string guard
(verified to fail via stash/run/pop when the guard is reverted).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230
AndresL230 merged commit c19a880 into mainAug 6, 2026
7 checks passed
@AndresL230
AndresL230 deleted the feat/522-c-521-quiz branch August 6, 2026 01:28
AndresL230 added a commit that referenced this pull request Aug 13, 2026
…L sweep, resume, paginated history (#542) (#550)
* feat(quiz): attempt lifecycle — mastery snapshot, derived status + TTL sweep, resume, paginated history (#542)
Workstream D of the pre-revamp quiz repair batch (epic #537):
D1 — submit persists mastery_before/mastery_after on the attempt row
(migration 20260813013547; plaintext analytics scalars per #521), so a
replayed or audited submit can reconstruct what the student saw.
D2 — status is DERIVED, never stored: completed_at → completed,
abandoned_at → abandoned, else in_progress; an in-progress row past
QUIZ_ATTEMPT_ABANDON_TTL_HOURS (24h, documented) reads as abandoned even
before the lazy per-user sweep stamps abandoned_at on the read paths
(no scheduler needed; conditional-update filters arbitrate).
GET /api/quiz/attempts/{id} returns resume state: questions WITHOUT the
answer key plus the responses recorded through /answer.
D3 — quizzes_completed counts completed attempts only; generate writes
the attempt row before the student answers anything, so the unfiltered
count let "generate and close the tab" advance quizzes_10. Blast radius
(measured on staging 2026-08-12): 1 user, 2 attempts, 0 completed,
badge never granted — nobody loses anything; prod recheck noted in the
PR. No revocation in any case.
D4 — GET /api/quiz/attempts: paginated history (concept, course, score,
total, difficulty, mastery delta, dates) — the plaintext scalars kept
for exactly this purpose in #521/#527 finally have a reader.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(quiz): real-DB coverage for the attempt lifecycle columns and sweep (#542)
The mastery snapshot and abandonment sweep are pure DB behaviour: a
mocked table() accepts columns the migration never added (#265 drift
class), and the conditional-update filters that decide WHICH attempts get
swept only mean something against Postgres. Asserts the columns
round-trip and that the sweep touches the stale in-progress attempt while
leaving a fresh one and a completed one alone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(quiz): address #550 review — close the resume answer leak, make abandoned bite, harden history (#542)
Review findings (xhigh, 15 confirmed). The serious ones:
- The resume endpoint leaked the answer twice over: _strip_answer_key
dropped only per-option `correct` and shipped `explanation` (which
names the answer in prose), and being a DENYLIST it passed unknown
stored shapes straight through — the rich seed's legacy row exposes
its answer under `a` while getting an empty options list. It is an
ALLOWLIST now (id/question/concept_tested/difficulty + label/text),
and an unrecognised stored shape 409s QUIZ_ATTEMPT_NOT_RESUMABLE
rather than being projected at all.
- "Abandoned" was cosmetic: resume served the full question set and both
/answer and /submit accepted swept attempts, paying out mastery, XP and
achievements. Both write paths now 409 QUIZ_ATTEMPT_ABANDONED, and
resume returns no questions with resumable:false.
- quizzes_completed counted claimed-but-never-graded attempts, because
completed_at is stamped by the atomic claim BEFORE grading. It now also
requires a persisted score.
- The TTL keyed on created_at alone, so an attempt being actively
answered was swept. Status and sweep both consider the newest recorded
answer; the active attempt is exempted from the sweep.
- The stored mastery snapshot was submit's local prediction; it now
records what apply_graph_update actually wrote (it clamps and resolves
by concept name), so history can't show progression the graph refused.
- The sweep is a write on a GET: it now sends Prefer: return=minimal
(new db/connection.py option) instead of dragging every swept row —
encrypted blobs included — back on each history page load.
- history: offset clamped at the top (an unbounded value 500s as
bigint-out-of-range) and ordered created_at.desc,id.desc so rows
sharing a timestamp can't repeat or vanish across pages.
- _attempt_status parsed naive timestamps fine and then raised TypeError
comparing them to an aware cutoff, past the ValueError guard; a shared
_parse_ts assumes UTC for naive values.
- Migration DDL is idempotent (IF NOT EXISTS) per the repo rule, plus a
partial index for the sweep/history predicate.
Test gaps the review found: the history "no question payloads" assertion
was vacuous (it now asserts the SELECTED COLUMNS), and neither new GET
had ownership coverage — the hermetic lane structurally cannot provide it
(require_self is stubbed there), so the IDOR negatives live in the
integration lane with real sessions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

Quiz performance data stored in plaintext (questions_json, answers_json, quiz_context)

1 participant

@AndresL230