feat(quiz): flip include_answer_key default to false (#546) - #590

Merged
AndresL230 merged 4 commits into
mainfrom
feat/546-answer-key-default-off
Aug 26, 2026
Merged

feat(quiz): flip include_answer_key default to false (#546)#590
AndresL230 merged 4 commits into
mainfrom
feat/546-answer-key-default-off

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

The #537 client grades every question through POST /attempts/{id}/answer
and already sends include_answer_key: false on every /api/quiz/generate
call (frontend/src/lib/quiz/api.ts, pinned by api.test.ts and the e2e
quiz specs), so the generate response's per-option answer key is dead weight
for the real caller. This flips GenerateQuizBody.include_answer_key's
default from true to false.

The parameter itself is not deleted — an explicit
include_answer_key: true still returns the full keyed shape and still logs
the #546 deprecation breadcrumb, kept accepted-but-logged for one release
per the plan. _INTERNAL_QUESTION_KEYS (provenance, question_hash) was
already stripped in both flag states and is unchanged.

Key table (both flag states) and the frontend/e2e audit (no bug found — every
real caller already sends the flag explicitly) are in the task report.

Frontend note (no code change here):frontend/src/lib/quiz/api.ts:32-33's
comment currently says "Removing the flag entirely is #546" — true of the
issue eventually, but this PR only flips the default; the comment now
overstates what landed tonight. Left as-is (out of this task's scope) — worth
a one-line tidy whenever the follow-up PR deletes the parameter.

Test plan

  • venv/bin/python -m pytest tests/ -q — 2225 passed, 80 skipped (was
    2223/79 on main; +1 new hermetic test passing, +1 new integration test
    cleanly skipped without RUN_INTEGRATION=1)
  • RED confirmed before test updates (2 failures from the default flip
    alone), and RED confirmed for the new grounded test specifically (by
    temporarily reverting the default and rerunning) — see report for both
    transcripts
  • venv/bin/ruff check — clean on all changed files
  • Browser/E2E lane (frontend/e2e/quiz*.spec.ts + oracles) — not run by
    the implementer per contract; controller's lane run
  • tests/integration/test_quiz_subcutaneous_db.py (new test included) —
    needs RUN_INTEGRATION=1 + local stack + function mode; collection
    verified only, controller's lane run

Refs #546

Lane runs (overnight 2026-08-23, local stack under the flock, function mode)

  • ruff ✅ · hermetic pytest ✅ 2225 passed / 80 skipped · eslint ✅ · tsc ✅ · vitest ✅
  • Playwright Chapter 1: 72 passed / 1 skipped / 1 failed → classified flake: gradebook.spec.ts:35 ("a course taken in two terms opens the right enrollment from each chip", "Exams" not visible after 7.8 s). Unrelated to this diff (quiz model default + comments + tests); the same spec passed in two other lanes on the same base tonight and 6/6 on a ×3 re-run of this branch. Worth a look for the test(e2e): browser-lane stability gate — 20 consecutive green runs #388 zero-flake gate.
  • oracles ✅ clean · integration ✅ 72 passed — includes the new subcutaneous no-leak test (tests/integration/test_quiz_subcutaneous_db.py), so the full-response walk + hard-coded key-set allowlist ran against real HTTP under the seam.
  • CI browser lane dispatched on this branch: https://github.com/SaplingLearn/Sapling/actions/runs/32629407089
  • Review: task review (2 Important on the no-leak test — walk scoped to questions only, grounding assertion circular through _strip_answer_key → both fixed) + scoped re-review clean.

Merge-gate review (2026-08-26)

/code-review at the merge gate: the flip itself reviewed safe for every
in-repo caller. All 15 findings were telemetry, test strength, or stale
comments; all are fixed in c412db8a + ac13c627.

Deprecation telemetry, made countable (F1, F4)

The grace window was gated on a logger.info — nothing rolls log lines up,
and the breadcrumb only ever fired on an explicit true, so the population
the window actually exists for (callers that OMIT the flag, whose response
shape silently changed at the flip) had zero telemetry. The route now
tells the three populations apart by "include_answer_key" in body.model_fields_set — was the field on the wire at all — rather than by
its value, and emits an events_service event per population (#117), which
lands in the #375 admin-analytics by_event_type rollup with no schema or
endpoint work:

callerresponseeventwhat it means for the #546 deletion gate
include_answer_key: truekeyedquiz.answer_key_served (usage, payload {quiz_id})the gate. A straggler really received the answer key; the parameter cannot be deleted until this count is zero across a release. Keeps the existing log line too.
field omittedkeylessquiz.answer_key_flag_omitted (usage, payload {quiz_id})flag-unaware caller, already on the shape #546 ends at. Deletion is a no-op for them — but they are the population that changed shape at the flip, so they stay visible while the window is open.
include_answer_key: falsekeyless(none)every shipped #537 client, on every generate. An event here would be a row per quiz, swamping the rollup to say something already known.

Two event types rather than one carrying a flag payload field: the
by_event_type rollup does not break payloads out, so a single type would
surface one number mixing the population that blocks deletion with the one
that doesn't — exactly the distinction the gate needs. Both are added to
EVENT_TAXONOMY (+ the pin in test_event_capture_seams.py); no migration,
no agent/seam change, and the emit is fire-and-forget like quiz.started
25 lines above it.

Projection tests, grounded (F2, F3, F9, F11, F12)

Both "keyless projection of the real stored key" tests asserted the served
shape but never that it actually was the projection: they would have
passed on an empty questions list. They now assert count equality against
the decrypted stored questions, per-index id/stem/option label+text
correspondence, and delivered_count computed off the projected list. The
~30-line leak block (allowlist + key-set loops + recursive walk) was
duplicated across the hermetic and subcutaneous lanes and had already
drifted; it is now one assert_keyless_projection fixture in
backend/tests/conftest.py that both lanes use (the allowlists stay literals
written test-side, never imported from routes.quiz, so the non-circularity
argument is unchanged). Option key drift is reported as a symmetric
difference, so a missing key reports itself; the exact top-level response
key set is now pinned.

Comment sweep (F5–F8, F10, F13–F15)

Four docstrings asserted things that stopped being true at the flip (the
keyed shape is not "what submit_quiz expects" — submit grades from the
stored questions_json and consumes only question_id/selected_label; the
#537 client has always sent explicit false; the keyed branch is no longer
the default). The lifecycle prose was restated at six sites and is now
canonical on GenerateQuizBody.include_answer_key with the rest pointing at
it. The frontend/src/lib/quiz/api.ts comment noted-but-not-fixed above is
fixed here
(F8) — it claimed the default is true and framed the client's
explicit false as load-bearing; it is now belt-and-braces, and the comment
says why the client keeps sending it anyway (so it stays out of the
quiz.answer_key_flag_omitted count). Default-{} and explicit-false are
consolidated under one parametrize, keeping the caplog-silence assert.

Merge-gate verification

  • venv/bin/python -m pytest tests/ -q -p no:cacheprovider2229
    passed, 80 skipped
    (+4 telemetry tests vs. the run above)
  • RED evidence for the telemetry pair: 3 failures before the route
    change (quiz.answer_key_served / ..._flag_omitted absent from both
    the sink and EVENT_TAXONOMY), green after
  • venv/bin/ruff check . — All checks passed
  • npx tsc --noEmit (only a comment changed in api.ts) — clean
  • tests/integration/test_quiz_subcutaneous_db.py collect + --setup-plan
    — 15 collected, assert_keyless_projection resolves in that lane
  • E2E lanes re-run by the controller (not booted by the implementer)

AndresL230and others added 2 commits August 23, 2026 04:02
The #537 client grades every question through POST
/attempts/{id}/answer and has sent `include_answer_key: false` on
every /api/quiz/generate call since it shipped (frontend/src/lib/quiz/api.ts,
tested in api.test.ts and modeled by the e2e quiz specs), so the
generate response's per-option answer key is dead weight for the
real caller. Flip GenerateQuizBody.include_answer_key's default to
false; the field itself stays accepted-but-logged for one release
(explicit `include_answer_key: true` still returns `correct` booleans
and logs the #546 deprecation breadcrumb) rather than being deleted
outright.
_INTERNAL_QUESTION_KEYS (provenance, question_hash) already stripped
in both flag states, unaffected by this change.
Tests:
- test_quiz_answers_c.py::TestIncludeAnswerKey: rewritten for the new
default (test_default_now_strips_the_key asserts the omitted-flag
path is keyless and does NOT log; test_explicit_true_keeps_the_key_and_logs
covers the still-accepted opt-in path) + a new hermetic test
(test_default_response_is_the_keyless_projection_of_the_real_stored_key)
that grounds the "no leak" claim against the server's own
_strip_answer_key projection of the real encrypted questions_json,
plus a generic recursive key-name walk, rather than just checking
`correct` is absent.
- test_quiz_routes.py::test_returns_agent_output_in_legacy_wire_shape:
now opts in explicitly (`include_answer_key: true`) since it pins
the full keyed wire shape, which is no longer the default.
- tests/integration/test_quiz_subcutaneous_db.py (#545): added the
real-Postgres twin of the hermetic grounding test above
(test_generate_default_response_never_reveals_the_correct_option),
marked integration; unrun here (needs the local stack + function
mode), collection verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…view r1)
Fix round 1 on the task-3 review (task-3-findings-r1.md):
Important 1 — the recursive key-name walk in both the hermetic test
(test_quiz_answers_c.py) and its subcutaneous DB twin
(test_quiz_subcutaneous_db.py) only covered `{"questions": served}`,
missing generate's five other top-level fields (quiz_id,
requested_difficulty, resolved_difficulty, requested_count,
delivered_count). A future sibling key (e.g. `answer_key`) would have
sailed past it. Both now walk the full response body.
Important 2 — `served == _strip_answer_key(stored)` compared the
route's default-path output against the SAME function that produced
it, so it could not catch a leak introduced inside that projection
(or an accidental widening of `_KEYLESS_*_KEYS`) — falsifiable by
widening the allowlist while both sides still move together. Replaced
with a non-circular anchor: each served question's key set must be a
subset of, and each option's key set must equal exactly, a HARD-CODED
literal written in the test (not imported from routes.quiz). Verified
this actually discriminates by temporarily reverting the model
default to True and confirming both tests fail with "unexpected
key(s): {'explanation'}" before restoring it. Docstrings on both
tests rewritten to stop overclaiming ("any leak... shows up as a
diff") and describe the key-name walk as a heuristic backstop, not a
proof.
M4 — test_quiz_subcutaneous_db.py's new test now guards
`_attempt_row(...)` with an `is not None` assertion before
subscripting, matching the neighbouring test's pattern.
M6 — both caplog assertions in TestIncludeAnswerKey now filter
`caplog.records` by `rec.name == "routes.quiz"` before scanning for
the deprecation breadcrumb, since caplog's handler captures every
propagating logger.
M3 (frontend/src/lib/quiz/api.ts:32-33 now overstates #546's tonight
scope) is note-only per the reviewer; not fixed here — noted in the
task report and PR body.
Covering tests: tests/test_quiz_answers_c.py (16 -> unchanged count,
docstrings/assertions only) + tests/test_quiz_provenance_e5_e6.py —
40 passed. tests/integration/test_quiz_subcutaneous_db.py collect-only
verified (needs the real stack to execute). Full hermetic suite:
2225 passed, 80 skipped (unchanged from before this fix round).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Aug 23, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 2 minutes.

View limit details

Limit details: You’ve used the included review currently available.

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

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6158ec31-9490-4a9b-8096-f6f08a7d8272

📥 Commits

Reviewing files that changed from the base of the PR and between 9f34454 and ac13c62.

📒 Files selected for processing (10)
  • backend/models/__init__.py
  • backend/routes/quiz.py
  • backend/services/events_service.py
  • backend/tests/conftest.py
  • backend/tests/integration/test_quiz_subcutaneous_db.py
  • backend/tests/test_event_capture_seams.py
  • backend/tests/test_quiz_answers_c.py
  • backend/tests/test_quiz_provenance_e5_e6.py
  • backend/tests/test_quiz_routes.py
  • frontend/src/lib/quiz/api.ts

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

❤️ Share

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

@cloudflare-workers-and-pages

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

Branch Preview URL
Aug 26 2026, 06:06 AM

@supabase

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

AndresL230and others added 2 commits August 26, 2026 02:03
…w r2)
The one-release grace window for `include_answer_key` was gated on a
`logger.info`: nothing rolls log lines up, and the breadcrumb only ever
fired on an explicit true, so the population the window actually exists
for — flag-unaware callers that OMIT the field and silently changed
response shape at the flip — had no telemetry at all (F1).
Distinguish the three populations by `model_fields_set` (was the field on
the wire?) rather than by value, and emit an events_service event per
population so the count lands in the #375 admin-analytics by_event_type
rollup with no schema or endpoint work (F4, #117 convention):
* explicit true -> quiz.answer_key_served (the count that must reach
zero before the parameter is deleted; keeps the log line too)
* omitted -> quiz.answer_key_flag_omitted (deletion is a no-op
for them, but they are the shape-change population)
* explicit false -> nothing; every shipped #537 client sends this on
every generate and would swamp the rollup
Two event types rather than one with a `flag` payload field because
by_event_type does not break payloads out: one type would show a single
number mixing the population that blocks deletion with the one that
doesn't.
Also canonicalizes the flag's lifecycle prose on the field declaration
(F14) — it was restated at six sites.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…review r2)
Test strength:
- Both "keyless projection of the real stored key" tests asserted the
served shape but never that it WAS the projection: add count equality
vs the decrypted stored questions, per-index id/stem/option label+text
correspondence, and delivered_count computed off the projected list
(F2, F3).
- The ~30-line leak check (allowlist + key-set loops + recursive walk)
was duplicated across the hermetic and subcutaneous lanes and had
already drifted; it moves to one `assert_keyless_projection` fixture in
tests/conftest.py, which both lanes use (F11). Sharing a test-side
helper is not the circularity the anchor guards against — the
allowlists are still literals, never imported from routes.quiz.
- Option key drift now reports the symmetric difference, so a MISSING key
reports itself instead of only extras (F9).
- Pin the exact top-level response key set (F12).
- Consolidate the default-{} and explicit-false cases via parametrize,
keeping the caplog-silence assert (F13).
- Cover the three telemetry populations, including the no-event case.
Comments:
- test_quiz_routes: the keyed shape is not "what submit_quiz expects" —
submit grades from stored questions_json and reads only
question_id/selected_label (F5).
- test_quiz_answers_c: the #537 client has ALWAYS sent explicit false;
the omitting population is flag-unaware callers (F6).
- test_quiz_provenance: the keyed branch is no longer the default — this
PR is the flip (F7).
- frontend quiz/api.ts: the default is false now, so the client's
explicit false is belt-and-braces, not load-bearing (F8).
- Fix the vacuity-guard wording: the anchors sit below, not above (F10).
- Parse the subcutaneous response once (F15).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 merged commit 025474a into mainAug 26, 2026
8 checks passed
AndresL230 added a commit that referenced this pull request Aug 26, 2026
Brings in the include_answer_key default flip (#590), the dead
effective_explanations deletion (#587) and the CI port preflight (#588).
Clean auto-merge: main's routes/quiz.py hunks are in the generate
handler's response projection, this branch's are in the shared
gather/hoist above it.
AndresL230 added a commit that referenced this pull request Aug 28, 2026
Two conflicts, both in the files #590 and this branch both edited.
models/__init__.py: #590 flipped `include_answer_key`'s default to false and
replaced its comment with the canonical lifecycle account; this branch added
`source_attempt_id` / `missed_question_hashes` and the validator that ties
them together. Kept both — the flipped default and its prose verbatim, the
G5 fields ahead of it, the validator after.
routes/quiz.py: this branch relocated the agent-call except ladder out of
`generate_quiz` into `_generate_or_502` so the re-serve branch can catch a
failed top-up and still serve what it recovered; main (#592) added
`has_graph=True` to the same call. Kept the relocation and carried the new
argument (and its rationale) into the helper. #590's keyless projection,
#591's attempt helpers and #592's hoisted lookups merged cleanly and are
untouched — re-served questions still flow through the single
`_client_questions` call, which strips `question_hash`/`provenance` on both
the keyless and the opt-in keyed branch.
Two G5 tests were written against the pre-flip default and are re-pointed:
the keyed-projection test now opts in explicitly, and the keyless one adopts
main's shared `assert_keyless_projection` fixture, which grounds the check in
the answer key actually stored for the attempt. That fixture then caught G5's
conditional top-level `source` block, so the shared key literal now names
optional fields instead of requiring every response to carry them. The F5
assertions narrow to `quiz_reserve.missed_questions`: #592 makes an unrelated
reporter fire on the same request under these mocks.
Co-Authored-By: Claude Opus 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.

1 participant

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

feat(quiz): flip include_answer_key default to false (#546) - #590

Merged
AndresL230 merged 4 commits into
mainfrom
feat/546-answer-key-default-off
Aug 26, 2026
Merged

feat(quiz): flip include_answer_key default to false (#546)#590
AndresL230 merged 4 commits into
mainfrom
feat/546-answer-key-default-off

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

The #537 client grades every question through POST /attempts/{id}/answer
and already sends include_answer_key: false on every /api/quiz/generate
call (frontend/src/lib/quiz/api.ts, pinned by api.test.ts and the e2e
quiz specs), so the generate response's per-option answer key is dead weight
for the real caller. This flips GenerateQuizBody.include_answer_key's
default from true to false.

The parameter itself is not deleted — an explicit
include_answer_key: true still returns the full keyed shape and still logs
the #546 deprecation breadcrumb, kept accepted-but-logged for one release
per the plan. _INTERNAL_QUESTION_KEYS (provenance, question_hash) was
already stripped in both flag states and is unchanged.

Key table (both flag states) and the frontend/e2e audit (no bug found — every
real caller already sends the flag explicitly) are in the task report.

Frontend note (no code change here):frontend/src/lib/quiz/api.ts:32-33's
comment currently says "Removing the flag entirely is #546" — true of the
issue eventually, but this PR only flips the default; the comment now
overstates what landed tonight. Left as-is (out of this task's scope) — worth
a one-line tidy whenever the follow-up PR deletes the parameter.

Test plan

  • venv/bin/python -m pytest tests/ -q — 2225 passed, 80 skipped (was
    2223/79 on main; +1 new hermetic test passing, +1 new integration test
    cleanly skipped without RUN_INTEGRATION=1)
  • RED confirmed before test updates (2 failures from the default flip
    alone), and RED confirmed for the new grounded test specifically (by
    temporarily reverting the default and rerunning) — see report for both
    transcripts
  • venv/bin/ruff check — clean on all changed files
  • Browser/E2E lane (frontend/e2e/quiz*.spec.ts + oracles) — not run by
    the implementer per contract; controller's lane run
  • tests/integration/test_quiz_subcutaneous_db.py (new test included) —
    needs RUN_INTEGRATION=1 + local stack + function mode; collection
    verified only, controller's lane run

Refs #546

Lane runs (overnight 2026-08-23, local stack under the flock, function mode)

  • ruff ✅ · hermetic pytest ✅ 2225 passed / 80 skipped · eslint ✅ · tsc ✅ · vitest ✅
  • Playwright Chapter 1: 72 passed / 1 skipped / 1 failed → classified flake: gradebook.spec.ts:35 ("a course taken in two terms opens the right enrollment from each chip", "Exams" not visible after 7.8 s). Unrelated to this diff (quiz model default + comments + tests); the same spec passed in two other lanes on the same base tonight and 6/6 on a ×3 re-run of this branch. Worth a look for the test(e2e): browser-lane stability gate — 20 consecutive green runs #388 zero-flake gate.
  • oracles ✅ clean · integration ✅ 72 passed — includes the new subcutaneous no-leak test (tests/integration/test_quiz_subcutaneous_db.py), so the full-response walk + hard-coded key-set allowlist ran against real HTTP under the seam.
  • CI browser lane dispatched on this branch: https://github.com/SaplingLearn/Sapling/actions/runs/32629407089
  • Review: task review (2 Important on the no-leak test — walk scoped to questions only, grounding assertion circular through _strip_answer_key → both fixed) + scoped re-review clean.

Merge-gate review (2026-08-26)

/code-review at the merge gate: the flip itself reviewed safe for every
in-repo caller. All 15 findings were telemetry, test strength, or stale
comments; all are fixed in c412db8a + ac13c627.

Deprecation telemetry, made countable (F1, F4)

The grace window was gated on a logger.info — nothing rolls log lines up,
and the breadcrumb only ever fired on an explicit true, so the population
the window actually exists for (callers that OMIT the flag, whose response
shape silently changed at the flip) had zero telemetry. The route now
tells the three populations apart by "include_answer_key" in body.model_fields_set — was the field on the wire at all — rather than by
its value, and emits an events_service event per population (#117), which
lands in the #375 admin-analytics by_event_type rollup with no schema or
endpoint work:

callerresponseeventwhat it means for the #546 deletion gate
include_answer_key: truekeyedquiz.answer_key_served (usage, payload {quiz_id})the gate. A straggler really received the answer key; the parameter cannot be deleted until this count is zero across a release. Keeps the existing log line too.
field omittedkeylessquiz.answer_key_flag_omitted (usage, payload {quiz_id})flag-unaware caller, already on the shape #546 ends at. Deletion is a no-op for them — but they are the population that changed shape at the flip, so they stay visible while the window is open.
include_answer_key: falsekeyless(none)every shipped #537 client, on every generate. An event here would be a row per quiz, swamping the rollup to say something already known.

Two event types rather than one carrying a flag payload field: the
by_event_type rollup does not break payloads out, so a single type would
surface one number mixing the population that blocks deletion with the one
that doesn't — exactly the distinction the gate needs. Both are added to
EVENT_TAXONOMY (+ the pin in test_event_capture_seams.py); no migration,
no agent/seam change, and the emit is fire-and-forget like quiz.started
25 lines above it.

Projection tests, grounded (F2, F3, F9, F11, F12)

Both "keyless projection of the real stored key" tests asserted the served
shape but never that it actually was the projection: they would have
passed on an empty questions list. They now assert count equality against
the decrypted stored questions, per-index id/stem/option label+text
correspondence, and delivered_count computed off the projected list. The
~30-line leak block (allowlist + key-set loops + recursive walk) was
duplicated across the hermetic and subcutaneous lanes and had already
drifted; it is now one assert_keyless_projection fixture in
backend/tests/conftest.py that both lanes use (the allowlists stay literals
written test-side, never imported from routes.quiz, so the non-circularity
argument is unchanged). Option key drift is reported as a symmetric
difference, so a missing key reports itself; the exact top-level response
key set is now pinned.

Comment sweep (F5–F8, F10, F13–F15)

Four docstrings asserted things that stopped being true at the flip (the
keyed shape is not "what submit_quiz expects" — submit grades from the
stored questions_json and consumes only question_id/selected_label; the
#537 client has always sent explicit false; the keyed branch is no longer
the default). The lifecycle prose was restated at six sites and is now
canonical on GenerateQuizBody.include_answer_key with the rest pointing at
it. The frontend/src/lib/quiz/api.ts comment noted-but-not-fixed above is
fixed here
(F8) — it claimed the default is true and framed the client's
explicit false as load-bearing; it is now belt-and-braces, and the comment
says why the client keeps sending it anyway (so it stays out of the
quiz.answer_key_flag_omitted count). Default-{} and explicit-false are
consolidated under one parametrize, keeping the caplog-silence assert.

Merge-gate verification

  • venv/bin/python -m pytest tests/ -q -p no:cacheprovider2229
    passed, 80 skipped
    (+4 telemetry tests vs. the run above)
  • RED evidence for the telemetry pair: 3 failures before the route
    change (quiz.answer_key_served / ..._flag_omitted absent from both
    the sink and EVENT_TAXONOMY), green after
  • venv/bin/ruff check . — All checks passed
  • npx tsc --noEmit (only a comment changed in api.ts) — clean
  • tests/integration/test_quiz_subcutaneous_db.py collect + --setup-plan
    — 15 collected, assert_keyless_projection resolves in that lane
  • E2E lanes re-run by the controller (not booted by the implementer)

AndresL230and others added 2 commits August 23, 2026 04:02
The #537 client grades every question through POST
/attempts/{id}/answer and has sent `include_answer_key: false` on
every /api/quiz/generate call since it shipped (frontend/src/lib/quiz/api.ts,
tested in api.test.ts and modeled by the e2e quiz specs), so the
generate response's per-option answer key is dead weight for the
real caller. Flip GenerateQuizBody.include_answer_key's default to
false; the field itself stays accepted-but-logged for one release
(explicit `include_answer_key: true` still returns `correct` booleans
and logs the #546 deprecation breadcrumb) rather than being deleted
outright.
_INTERNAL_QUESTION_KEYS (provenance, question_hash) already stripped
in both flag states, unaffected by this change.
Tests:
- test_quiz_answers_c.py::TestIncludeAnswerKey: rewritten for the new
default (test_default_now_strips_the_key asserts the omitted-flag
path is keyless and does NOT log; test_explicit_true_keeps_the_key_and_logs
covers the still-accepted opt-in path) + a new hermetic test
(test_default_response_is_the_keyless_projection_of_the_real_stored_key)
that grounds the "no leak" claim against the server's own
_strip_answer_key projection of the real encrypted questions_json,
plus a generic recursive key-name walk, rather than just checking
`correct` is absent.
- test_quiz_routes.py::test_returns_agent_output_in_legacy_wire_shape:
now opts in explicitly (`include_answer_key: true`) since it pins
the full keyed wire shape, which is no longer the default.
- tests/integration/test_quiz_subcutaneous_db.py (#545): added the
real-Postgres twin of the hermetic grounding test above
(test_generate_default_response_never_reveals_the_correct_option),
marked integration; unrun here (needs the local stack + function
mode), collection verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…view r1)
Fix round 1 on the task-3 review (task-3-findings-r1.md):
Important 1 — the recursive key-name walk in both the hermetic test
(test_quiz_answers_c.py) and its subcutaneous DB twin
(test_quiz_subcutaneous_db.py) only covered `{"questions": served}`,
missing generate's five other top-level fields (quiz_id,
requested_difficulty, resolved_difficulty, requested_count,
delivered_count). A future sibling key (e.g. `answer_key`) would have
sailed past it. Both now walk the full response body.
Important 2 — `served == _strip_answer_key(stored)` compared the
route's default-path output against the SAME function that produced
it, so it could not catch a leak introduced inside that projection
(or an accidental widening of `_KEYLESS_*_KEYS`) — falsifiable by
widening the allowlist while both sides still move together. Replaced
with a non-circular anchor: each served question's key set must be a
subset of, and each option's key set must equal exactly, a HARD-CODED
literal written in the test (not imported from routes.quiz). Verified
this actually discriminates by temporarily reverting the model
default to True and confirming both tests fail with "unexpected
key(s): {'explanation'}" before restoring it. Docstrings on both
tests rewritten to stop overclaiming ("any leak... shows up as a
diff") and describe the key-name walk as a heuristic backstop, not a
proof.
M4 — test_quiz_subcutaneous_db.py's new test now guards
`_attempt_row(...)` with an `is not None` assertion before
subscripting, matching the neighbouring test's pattern.
M6 — both caplog assertions in TestIncludeAnswerKey now filter
`caplog.records` by `rec.name == "routes.quiz"` before scanning for
the deprecation breadcrumb, since caplog's handler captures every
propagating logger.
M3 (frontend/src/lib/quiz/api.ts:32-33 now overstates #546's tonight
scope) is note-only per the reviewer; not fixed here — noted in the
task report and PR body.
Covering tests: tests/test_quiz_answers_c.py (16 -> unchanged count,
docstrings/assertions only) + tests/test_quiz_provenance_e5_e6.py —
40 passed. tests/integration/test_quiz_subcutaneous_db.py collect-only
verified (needs the real stack to execute). Full hermetic suite:
2225 passed, 80 skipped (unchanged from before this fix round).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Aug 23, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 2 minutes.

View limit details

Limit details: You’ve used the included review currently available.

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

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6158ec31-9490-4a9b-8096-f6f08a7d8272

📥 Commits

Reviewing files that changed from the base of the PR and between 9f34454 and ac13c62.

📒 Files selected for processing (10)
  • backend/models/__init__.py
  • backend/routes/quiz.py
  • backend/services/events_service.py
  • backend/tests/conftest.py
  • backend/tests/integration/test_quiz_subcutaneous_db.py
  • backend/tests/test_event_capture_seams.py
  • backend/tests/test_quiz_answers_c.py
  • backend/tests/test_quiz_provenance_e5_e6.py
  • backend/tests/test_quiz_routes.py
  • frontend/src/lib/quiz/api.ts

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

❤️ Share

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

@cloudflare-workers-and-pages

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

Branch Preview URL
Aug 26 2026, 06:06 AM

@supabase

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

AndresL230and others added 2 commits August 26, 2026 02:03
…w r2)
The one-release grace window for `include_answer_key` was gated on a
`logger.info`: nothing rolls log lines up, and the breadcrumb only ever
fired on an explicit true, so the population the window actually exists
for — flag-unaware callers that OMIT the field and silently changed
response shape at the flip — had no telemetry at all (F1).
Distinguish the three populations by `model_fields_set` (was the field on
the wire?) rather than by value, and emit an events_service event per
population so the count lands in the #375 admin-analytics by_event_type
rollup with no schema or endpoint work (F4, #117 convention):
* explicit true -> quiz.answer_key_served (the count that must reach
zero before the parameter is deleted; keeps the log line too)
* omitted -> quiz.answer_key_flag_omitted (deletion is a no-op
for them, but they are the shape-change population)
* explicit false -> nothing; every shipped #537 client sends this on
every generate and would swamp the rollup
Two event types rather than one with a `flag` payload field because
by_event_type does not break payloads out: one type would show a single
number mixing the population that blocks deletion with the one that
doesn't.
Also canonicalizes the flag's lifecycle prose on the field declaration
(F14) — it was restated at six sites.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…review r2)
Test strength:
- Both "keyless projection of the real stored key" tests asserted the
served shape but never that it WAS the projection: add count equality
vs the decrypted stored questions, per-index id/stem/option label+text
correspondence, and delivered_count computed off the projected list
(F2, F3).
- The ~30-line leak check (allowlist + key-set loops + recursive walk)
was duplicated across the hermetic and subcutaneous lanes and had
already drifted; it moves to one `assert_keyless_projection` fixture in
tests/conftest.py, which both lanes use (F11). Sharing a test-side
helper is not the circularity the anchor guards against — the
allowlists are still literals, never imported from routes.quiz.
- Option key drift now reports the symmetric difference, so a MISSING key
reports itself instead of only extras (F9).
- Pin the exact top-level response key set (F12).
- Consolidate the default-{} and explicit-false cases via parametrize,
keeping the caplog-silence assert (F13).
- Cover the three telemetry populations, including the no-event case.
Comments:
- test_quiz_routes: the keyed shape is not "what submit_quiz expects" —
submit grades from stored questions_json and reads only
question_id/selected_label (F5).
- test_quiz_answers_c: the #537 client has ALWAYS sent explicit false;
the omitting population is flag-unaware callers (F6).
- test_quiz_provenance: the keyed branch is no longer the default — this
PR is the flip (F7).
- frontend quiz/api.ts: the default is false now, so the client's
explicit false is belt-and-braces, not load-bearing (F8).
- Fix the vacuity-guard wording: the anchors sit below, not above (F10).
- Parse the subcutaneous response once (F15).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 merged commit 025474a into mainAug 26, 2026
8 checks passed
AndresL230 added a commit that referenced this pull request Aug 26, 2026
Brings in the include_answer_key default flip (#590), the dead
effective_explanations deletion (#587) and the CI port preflight (#588).
Clean auto-merge: main's routes/quiz.py hunks are in the generate
handler's response projection, this branch's are in the shared
gather/hoist above it.
AndresL230 added a commit that referenced this pull request Aug 28, 2026
Two conflicts, both in the files #590 and this branch both edited.
models/__init__.py: #590 flipped `include_answer_key`'s default to false and
replaced its comment with the canonical lifecycle account; this branch added
`source_attempt_id` / `missed_question_hashes` and the validator that ties
them together. Kept both — the flipped default and its prose verbatim, the
G5 fields ahead of it, the validator after.
routes/quiz.py: this branch relocated the agent-call except ladder out of
`generate_quiz` into `_generate_or_502` so the re-serve branch can catch a
failed top-up and still serve what it recovered; main (#592) added
`has_graph=True` to the same call. Kept the relocation and carried the new
argument (and its rationale) into the helper. #590's keyless projection,
#591's attempt helpers and #592's hoisted lookups merged cleanly and are
untouched — re-served questions still flow through the single
`_client_questions` call, which strips `question_hash`/`provenance` on both
the keyless and the opt-in keyed branch.
Two G5 tests were written against the pre-flip default and are re-pointed:
the keyed-projection test now opts in explicitly, and the keyless one adopts
main's shared `assert_keyless_projection` fixture, which grounds the check in
the answer key actually stored for the attempt. That fixture then caught G5's
conditional top-level `source` block, so the shared key literal now names
optional fields instead of requiring every response to carry them. The F5
assertions narrow to `quiz_reserve.missed_questions`: #592 makes an unrelated
reporter fire on the same request under these mocks.
Co-Authored-By: Claude Opus 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.

1 participant

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

feat(quiz): flip include_answer_key default to false (#546) - #590

Merged
AndresL230 merged 4 commits into
mainfrom
feat/546-answer-key-default-off
Aug 26, 2026
Merged

feat(quiz): flip include_answer_key default to false (#546)#590
AndresL230 merged 4 commits into
mainfrom
feat/546-answer-key-default-off

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

The #537 client grades every question through POST /attempts/{id}/answer
and already sends include_answer_key: false on every /api/quiz/generate
call (frontend/src/lib/quiz/api.ts, pinned by api.test.ts and the e2e
quiz specs), so the generate response's per-option answer key is dead weight
for the real caller. This flips GenerateQuizBody.include_answer_key's
default from true to false.

The parameter itself is not deleted — an explicit
include_answer_key: true still returns the full keyed shape and still logs
the #546 deprecation breadcrumb, kept accepted-but-logged for one release
per the plan. _INTERNAL_QUESTION_KEYS (provenance, question_hash) was
already stripped in both flag states and is unchanged.

Key table (both flag states) and the frontend/e2e audit (no bug found — every
real caller already sends the flag explicitly) are in the task report.

Frontend note (no code change here):frontend/src/lib/quiz/api.ts:32-33's
comment currently says "Removing the flag entirely is #546" — true of the
issue eventually, but this PR only flips the default; the comment now
overstates what landed tonight. Left as-is (out of this task's scope) — worth
a one-line tidy whenever the follow-up PR deletes the parameter.

Test plan

  • venv/bin/python -m pytest tests/ -q — 2225 passed, 80 skipped (was
    2223/79 on main; +1 new hermetic test passing, +1 new integration test
    cleanly skipped without RUN_INTEGRATION=1)
  • RED confirmed before test updates (2 failures from the default flip
    alone), and RED confirmed for the new grounded test specifically (by
    temporarily reverting the default and rerunning) — see report for both
    transcripts
  • venv/bin/ruff check — clean on all changed files
  • Browser/E2E lane (frontend/e2e/quiz*.spec.ts + oracles) — not run by
    the implementer per contract; controller's lane run
  • tests/integration/test_quiz_subcutaneous_db.py (new test included) —
    needs RUN_INTEGRATION=1 + local stack + function mode; collection
    verified only, controller's lane run

Refs #546

Lane runs (overnight 2026-08-23, local stack under the flock, function mode)

  • ruff ✅ · hermetic pytest ✅ 2225 passed / 80 skipped · eslint ✅ · tsc ✅ · vitest ✅
  • Playwright Chapter 1: 72 passed / 1 skipped / 1 failed → classified flake: gradebook.spec.ts:35 ("a course taken in two terms opens the right enrollment from each chip", "Exams" not visible after 7.8 s). Unrelated to this diff (quiz model default + comments + tests); the same spec passed in two other lanes on the same base tonight and 6/6 on a ×3 re-run of this branch. Worth a look for the test(e2e): browser-lane stability gate — 20 consecutive green runs #388 zero-flake gate.
  • oracles ✅ clean · integration ✅ 72 passed — includes the new subcutaneous no-leak test (tests/integration/test_quiz_subcutaneous_db.py), so the full-response walk + hard-coded key-set allowlist ran against real HTTP under the seam.
  • CI browser lane dispatched on this branch: https://github.com/SaplingLearn/Sapling/actions/runs/32629407089
  • Review: task review (2 Important on the no-leak test — walk scoped to questions only, grounding assertion circular through _strip_answer_key → both fixed) + scoped re-review clean.

Merge-gate review (2026-08-26)

/code-review at the merge gate: the flip itself reviewed safe for every
in-repo caller. All 15 findings were telemetry, test strength, or stale
comments; all are fixed in c412db8a + ac13c627.

Deprecation telemetry, made countable (F1, F4)

The grace window was gated on a logger.info — nothing rolls log lines up,
and the breadcrumb only ever fired on an explicit true, so the population
the window actually exists for (callers that OMIT the flag, whose response
shape silently changed at the flip) had zero telemetry. The route now
tells the three populations apart by "include_answer_key" in body.model_fields_set — was the field on the wire at all — rather than by
its value, and emits an events_service event per population (#117), which
lands in the #375 admin-analytics by_event_type rollup with no schema or
endpoint work:

callerresponseeventwhat it means for the #546 deletion gate
include_answer_key: truekeyedquiz.answer_key_served (usage, payload {quiz_id})the gate. A straggler really received the answer key; the parameter cannot be deleted until this count is zero across a release. Keeps the existing log line too.
field omittedkeylessquiz.answer_key_flag_omitted (usage, payload {quiz_id})flag-unaware caller, already on the shape #546 ends at. Deletion is a no-op for them — but they are the population that changed shape at the flip, so they stay visible while the window is open.
include_answer_key: falsekeyless(none)every shipped #537 client, on every generate. An event here would be a row per quiz, swamping the rollup to say something already known.

Two event types rather than one carrying a flag payload field: the
by_event_type rollup does not break payloads out, so a single type would
surface one number mixing the population that blocks deletion with the one
that doesn't — exactly the distinction the gate needs. Both are added to
EVENT_TAXONOMY (+ the pin in test_event_capture_seams.py); no migration,
no agent/seam change, and the emit is fire-and-forget like quiz.started
25 lines above it.

Projection tests, grounded (F2, F3, F9, F11, F12)

Both "keyless projection of the real stored key" tests asserted the served
shape but never that it actually was the projection: they would have
passed on an empty questions list. They now assert count equality against
the decrypted stored questions, per-index id/stem/option label+text
correspondence, and delivered_count computed off the projected list. The
~30-line leak block (allowlist + key-set loops + recursive walk) was
duplicated across the hermetic and subcutaneous lanes and had already
drifted; it is now one assert_keyless_projection fixture in
backend/tests/conftest.py that both lanes use (the allowlists stay literals
written test-side, never imported from routes.quiz, so the non-circularity
argument is unchanged). Option key drift is reported as a symmetric
difference, so a missing key reports itself; the exact top-level response
key set is now pinned.

Comment sweep (F5–F8, F10, F13–F15)

Four docstrings asserted things that stopped being true at the flip (the
keyed shape is not "what submit_quiz expects" — submit grades from the
stored questions_json and consumes only question_id/selected_label; the
#537 client has always sent explicit false; the keyed branch is no longer
the default). The lifecycle prose was restated at six sites and is now
canonical on GenerateQuizBody.include_answer_key with the rest pointing at
it. The frontend/src/lib/quiz/api.ts comment noted-but-not-fixed above is
fixed here
(F8) — it claimed the default is true and framed the client's
explicit false as load-bearing; it is now belt-and-braces, and the comment
says why the client keeps sending it anyway (so it stays out of the
quiz.answer_key_flag_omitted count). Default-{} and explicit-false are
consolidated under one parametrize, keeping the caplog-silence assert.

Merge-gate verification

  • venv/bin/python -m pytest tests/ -q -p no:cacheprovider2229
    passed, 80 skipped
    (+4 telemetry tests vs. the run above)
  • RED evidence for the telemetry pair: 3 failures before the route
    change (quiz.answer_key_served / ..._flag_omitted absent from both
    the sink and EVENT_TAXONOMY), green after
  • venv/bin/ruff check . — All checks passed
  • npx tsc --noEmit (only a comment changed in api.ts) — clean
  • tests/integration/test_quiz_subcutaneous_db.py collect + --setup-plan
    — 15 collected, assert_keyless_projection resolves in that lane
  • E2E lanes re-run by the controller (not booted by the implementer)

AndresL230and others added 2 commits August 23, 2026 04:02
The #537 client grades every question through POST
/attempts/{id}/answer and has sent `include_answer_key: false` on
every /api/quiz/generate call since it shipped (frontend/src/lib/quiz/api.ts,
tested in api.test.ts and modeled by the e2e quiz specs), so the
generate response's per-option answer key is dead weight for the
real caller. Flip GenerateQuizBody.include_answer_key's default to
false; the field itself stays accepted-but-logged for one release
(explicit `include_answer_key: true` still returns `correct` booleans
and logs the #546 deprecation breadcrumb) rather than being deleted
outright.
_INTERNAL_QUESTION_KEYS (provenance, question_hash) already stripped
in both flag states, unaffected by this change.
Tests:
- test_quiz_answers_c.py::TestIncludeAnswerKey: rewritten for the new
default (test_default_now_strips_the_key asserts the omitted-flag
path is keyless and does NOT log; test_explicit_true_keeps_the_key_and_logs
covers the still-accepted opt-in path) + a new hermetic test
(test_default_response_is_the_keyless_projection_of_the_real_stored_key)
that grounds the "no leak" claim against the server's own
_strip_answer_key projection of the real encrypted questions_json,
plus a generic recursive key-name walk, rather than just checking
`correct` is absent.
- test_quiz_routes.py::test_returns_agent_output_in_legacy_wire_shape:
now opts in explicitly (`include_answer_key: true`) since it pins
the full keyed wire shape, which is no longer the default.
- tests/integration/test_quiz_subcutaneous_db.py (#545): added the
real-Postgres twin of the hermetic grounding test above
(test_generate_default_response_never_reveals_the_correct_option),
marked integration; unrun here (needs the local stack + function
mode), collection verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…view r1)
Fix round 1 on the task-3 review (task-3-findings-r1.md):
Important 1 — the recursive key-name walk in both the hermetic test
(test_quiz_answers_c.py) and its subcutaneous DB twin
(test_quiz_subcutaneous_db.py) only covered `{"questions": served}`,
missing generate's five other top-level fields (quiz_id,
requested_difficulty, resolved_difficulty, requested_count,
delivered_count). A future sibling key (e.g. `answer_key`) would have
sailed past it. Both now walk the full response body.
Important 2 — `served == _strip_answer_key(stored)` compared the
route's default-path output against the SAME function that produced
it, so it could not catch a leak introduced inside that projection
(or an accidental widening of `_KEYLESS_*_KEYS`) — falsifiable by
widening the allowlist while both sides still move together. Replaced
with a non-circular anchor: each served question's key set must be a
subset of, and each option's key set must equal exactly, a HARD-CODED
literal written in the test (not imported from routes.quiz). Verified
this actually discriminates by temporarily reverting the model
default to True and confirming both tests fail with "unexpected
key(s): {'explanation'}" before restoring it. Docstrings on both
tests rewritten to stop overclaiming ("any leak... shows up as a
diff") and describe the key-name walk as a heuristic backstop, not a
proof.
M4 — test_quiz_subcutaneous_db.py's new test now guards
`_attempt_row(...)` with an `is not None` assertion before
subscripting, matching the neighbouring test's pattern.
M6 — both caplog assertions in TestIncludeAnswerKey now filter
`caplog.records` by `rec.name == "routes.quiz"` before scanning for
the deprecation breadcrumb, since caplog's handler captures every
propagating logger.
M3 (frontend/src/lib/quiz/api.ts:32-33 now overstates #546's tonight
scope) is note-only per the reviewer; not fixed here — noted in the
task report and PR body.
Covering tests: tests/test_quiz_answers_c.py (16 -> unchanged count,
docstrings/assertions only) + tests/test_quiz_provenance_e5_e6.py —
40 passed. tests/integration/test_quiz_subcutaneous_db.py collect-only
verified (needs the real stack to execute). Full hermetic suite:
2225 passed, 80 skipped (unchanged from before this fix round).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Aug 23, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 2 minutes.

View limit details

Limit details: You’ve used the included review currently available.

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

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6158ec31-9490-4a9b-8096-f6f08a7d8272

📥 Commits

Reviewing files that changed from the base of the PR and between 9f34454 and ac13c62.

📒 Files selected for processing (10)
  • backend/models/__init__.py
  • backend/routes/quiz.py
  • backend/services/events_service.py
  • backend/tests/conftest.py
  • backend/tests/integration/test_quiz_subcutaneous_db.py
  • backend/tests/test_event_capture_seams.py
  • backend/tests/test_quiz_answers_c.py
  • backend/tests/test_quiz_provenance_e5_e6.py
  • backend/tests/test_quiz_routes.py
  • frontend/src/lib/quiz/api.ts

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

❤️ Share

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

@cloudflare-workers-and-pages

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

Branch Preview URL
Aug 26 2026, 06:06 AM

@supabase

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

AndresL230and others added 2 commits August 26, 2026 02:03
…w r2)
The one-release grace window for `include_answer_key` was gated on a
`logger.info`: nothing rolls log lines up, and the breadcrumb only ever
fired on an explicit true, so the population the window actually exists
for — flag-unaware callers that OMIT the field and silently changed
response shape at the flip — had no telemetry at all (F1).
Distinguish the three populations by `model_fields_set` (was the field on
the wire?) rather than by value, and emit an events_service event per
population so the count lands in the #375 admin-analytics by_event_type
rollup with no schema or endpoint work (F4, #117 convention):
* explicit true -> quiz.answer_key_served (the count that must reach
zero before the parameter is deleted; keeps the log line too)
* omitted -> quiz.answer_key_flag_omitted (deletion is a no-op
for them, but they are the shape-change population)
* explicit false -> nothing; every shipped #537 client sends this on
every generate and would swamp the rollup
Two event types rather than one with a `flag` payload field because
by_event_type does not break payloads out: one type would show a single
number mixing the population that blocks deletion with the one that
doesn't.
Also canonicalizes the flag's lifecycle prose on the field declaration
(F14) — it was restated at six sites.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…review r2)
Test strength:
- Both "keyless projection of the real stored key" tests asserted the
served shape but never that it WAS the projection: add count equality
vs the decrypted stored questions, per-index id/stem/option label+text
correspondence, and delivered_count computed off the projected list
(F2, F3).
- The ~30-line leak check (allowlist + key-set loops + recursive walk)
was duplicated across the hermetic and subcutaneous lanes and had
already drifted; it moves to one `assert_keyless_projection` fixture in
tests/conftest.py, which both lanes use (F11). Sharing a test-side
helper is not the circularity the anchor guards against — the
allowlists are still literals, never imported from routes.quiz.
- Option key drift now reports the symmetric difference, so a MISSING key
reports itself instead of only extras (F9).
- Pin the exact top-level response key set (F12).
- Consolidate the default-{} and explicit-false cases via parametrize,
keeping the caplog-silence assert (F13).
- Cover the three telemetry populations, including the no-event case.
Comments:
- test_quiz_routes: the keyed shape is not "what submit_quiz expects" —
submit grades from stored questions_json and reads only
question_id/selected_label (F5).
- test_quiz_answers_c: the #537 client has ALWAYS sent explicit false;
the omitting population is flag-unaware callers (F6).
- test_quiz_provenance: the keyed branch is no longer the default — this
PR is the flip (F7).
- frontend quiz/api.ts: the default is false now, so the client's
explicit false is belt-and-braces, not load-bearing (F8).
- Fix the vacuity-guard wording: the anchors sit below, not above (F10).
- Parse the subcutaneous response once (F15).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 merged commit 025474a into mainAug 26, 2026
8 checks passed
AndresL230 added a commit that referenced this pull request Aug 26, 2026
Brings in the include_answer_key default flip (#590), the dead
effective_explanations deletion (#587) and the CI port preflight (#588).
Clean auto-merge: main's routes/quiz.py hunks are in the generate
handler's response projection, this branch's are in the shared
gather/hoist above it.
AndresL230 added a commit that referenced this pull request Aug 28, 2026
Two conflicts, both in the files #590 and this branch both edited.
models/__init__.py: #590 flipped `include_answer_key`'s default to false and
replaced its comment with the canonical lifecycle account; this branch added
`source_attempt_id` / `missed_question_hashes` and the validator that ties
them together. Kept both — the flipped default and its prose verbatim, the
G5 fields ahead of it, the validator after.
routes/quiz.py: this branch relocated the agent-call except ladder out of
`generate_quiz` into `_generate_or_502` so the re-serve branch can catch a
failed top-up and still serve what it recovered; main (#592) added
`has_graph=True` to the same call. Kept the relocation and carried the new
argument (and its rationale) into the helper. #590's keyless projection,
#591's attempt helpers and #592's hoisted lookups merged cleanly and are
untouched — re-served questions still flow through the single
`_client_questions` call, which strips `question_hash`/`provenance` on both
the keyless and the opt-in keyed branch.
Two G5 tests were written against the pre-flip default and are re-pointed:
the keyed-projection test now opts in explicitly, and the keyless one adopts
main's shared `assert_keyless_projection` fixture, which grounds the check in
the answer key actually stored for the attempt. That fixture then caught G5's
conditional top-level `source` block, so the shared key literal now names
optional fields instead of requiring every response to carry them. The F5
assertions narrow to `quiz_reserve.missed_questions`: #592 makes an unrelated
reporter fire on the same request under these mocks.
Co-Authored-By: Claude Opus 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.

1 participant

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

feat(quiz): flip include_answer_key default to false (#546) - #590

Merged
AndresL230 merged 4 commits into
mainfrom
feat/546-answer-key-default-off
Aug 26, 2026
Merged

feat(quiz): flip include_answer_key default to false (#546)#590
AndresL230 merged 4 commits into
mainfrom
feat/546-answer-key-default-off

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

The #537 client grades every question through POST /attempts/{id}/answer
and already sends include_answer_key: false on every /api/quiz/generate
call (frontend/src/lib/quiz/api.ts, pinned by api.test.ts and the e2e
quiz specs), so the generate response's per-option answer key is dead weight
for the real caller. This flips GenerateQuizBody.include_answer_key's
default from true to false.

The parameter itself is not deleted — an explicit
include_answer_key: true still returns the full keyed shape and still logs
the #546 deprecation breadcrumb, kept accepted-but-logged for one release
per the plan. _INTERNAL_QUESTION_KEYS (provenance, question_hash) was
already stripped in both flag states and is unchanged.

Key table (both flag states) and the frontend/e2e audit (no bug found — every
real caller already sends the flag explicitly) are in the task report.

Frontend note (no code change here):frontend/src/lib/quiz/api.ts:32-33's
comment currently says "Removing the flag entirely is #546" — true of the
issue eventually, but this PR only flips the default; the comment now
overstates what landed tonight. Left as-is (out of this task's scope) — worth
a one-line tidy whenever the follow-up PR deletes the parameter.

Test plan

  • venv/bin/python -m pytest tests/ -q — 2225 passed, 80 skipped (was
    2223/79 on main; +1 new hermetic test passing, +1 new integration test
    cleanly skipped without RUN_INTEGRATION=1)
  • RED confirmed before test updates (2 failures from the default flip
    alone), and RED confirmed for the new grounded test specifically (by
    temporarily reverting the default and rerunning) — see report for both
    transcripts
  • venv/bin/ruff check — clean on all changed files
  • Browser/E2E lane (frontend/e2e/quiz*.spec.ts + oracles) — not run by
    the implementer per contract; controller's lane run
  • tests/integration/test_quiz_subcutaneous_db.py (new test included) —
    needs RUN_INTEGRATION=1 + local stack + function mode; collection
    verified only, controller's lane run

Refs #546

Lane runs (overnight 2026-08-23, local stack under the flock, function mode)

  • ruff ✅ · hermetic pytest ✅ 2225 passed / 80 skipped · eslint ✅ · tsc ✅ · vitest ✅
  • Playwright Chapter 1: 72 passed / 1 skipped / 1 failed → classified flake: gradebook.spec.ts:35 ("a course taken in two terms opens the right enrollment from each chip", "Exams" not visible after 7.8 s). Unrelated to this diff (quiz model default + comments + tests); the same spec passed in two other lanes on the same base tonight and 6/6 on a ×3 re-run of this branch. Worth a look for the test(e2e): browser-lane stability gate — 20 consecutive green runs #388 zero-flake gate.
  • oracles ✅ clean · integration ✅ 72 passed — includes the new subcutaneous no-leak test (tests/integration/test_quiz_subcutaneous_db.py), so the full-response walk + hard-coded key-set allowlist ran against real HTTP under the seam.
  • CI browser lane dispatched on this branch: https://github.com/SaplingLearn/Sapling/actions/runs/32629407089
  • Review: task review (2 Important on the no-leak test — walk scoped to questions only, grounding assertion circular through _strip_answer_key → both fixed) + scoped re-review clean.

Merge-gate review (2026-08-26)

/code-review at the merge gate: the flip itself reviewed safe for every
in-repo caller. All 15 findings were telemetry, test strength, or stale
comments; all are fixed in c412db8a + ac13c627.

Deprecation telemetry, made countable (F1, F4)

The grace window was gated on a logger.info — nothing rolls log lines up,
and the breadcrumb only ever fired on an explicit true, so the population
the window actually exists for (callers that OMIT the flag, whose response
shape silently changed at the flip) had zero telemetry. The route now
tells the three populations apart by "include_answer_key" in body.model_fields_set — was the field on the wire at all — rather than by
its value, and emits an events_service event per population (#117), which
lands in the #375 admin-analytics by_event_type rollup with no schema or
endpoint work:

callerresponseeventwhat it means for the #546 deletion gate
include_answer_key: truekeyedquiz.answer_key_served (usage, payload {quiz_id})the gate. A straggler really received the answer key; the parameter cannot be deleted until this count is zero across a release. Keeps the existing log line too.
field omittedkeylessquiz.answer_key_flag_omitted (usage, payload {quiz_id})flag-unaware caller, already on the shape #546 ends at. Deletion is a no-op for them — but they are the population that changed shape at the flip, so they stay visible while the window is open.
include_answer_key: falsekeyless(none)every shipped #537 client, on every generate. An event here would be a row per quiz, swamping the rollup to say something already known.

Two event types rather than one carrying a flag payload field: the
by_event_type rollup does not break payloads out, so a single type would
surface one number mixing the population that blocks deletion with the one
that doesn't — exactly the distinction the gate needs. Both are added to
EVENT_TAXONOMY (+ the pin in test_event_capture_seams.py); no migration,
no agent/seam change, and the emit is fire-and-forget like quiz.started
25 lines above it.

Projection tests, grounded (F2, F3, F9, F11, F12)

Both "keyless projection of the real stored key" tests asserted the served
shape but never that it actually was the projection: they would have
passed on an empty questions list. They now assert count equality against
the decrypted stored questions, per-index id/stem/option label+text
correspondence, and delivered_count computed off the projected list. The
~30-line leak block (allowlist + key-set loops + recursive walk) was
duplicated across the hermetic and subcutaneous lanes and had already
drifted; it is now one assert_keyless_projection fixture in
backend/tests/conftest.py that both lanes use (the allowlists stay literals
written test-side, never imported from routes.quiz, so the non-circularity
argument is unchanged). Option key drift is reported as a symmetric
difference, so a missing key reports itself; the exact top-level response
key set is now pinned.

Comment sweep (F5–F8, F10, F13–F15)

Four docstrings asserted things that stopped being true at the flip (the
keyed shape is not "what submit_quiz expects" — submit grades from the
stored questions_json and consumes only question_id/selected_label; the
#537 client has always sent explicit false; the keyed branch is no longer
the default). The lifecycle prose was restated at six sites and is now
canonical on GenerateQuizBody.include_answer_key with the rest pointing at
it. The frontend/src/lib/quiz/api.ts comment noted-but-not-fixed above is
fixed here
(F8) — it claimed the default is true and framed the client's
explicit false as load-bearing; it is now belt-and-braces, and the comment
says why the client keeps sending it anyway (so it stays out of the
quiz.answer_key_flag_omitted count). Default-{} and explicit-false are
consolidated under one parametrize, keeping the caplog-silence assert.

Merge-gate verification

  • venv/bin/python -m pytest tests/ -q -p no:cacheprovider2229
    passed, 80 skipped
    (+4 telemetry tests vs. the run above)
  • RED evidence for the telemetry pair: 3 failures before the route
    change (quiz.answer_key_served / ..._flag_omitted absent from both
    the sink and EVENT_TAXONOMY), green after
  • venv/bin/ruff check . — All checks passed
  • npx tsc --noEmit (only a comment changed in api.ts) — clean
  • tests/integration/test_quiz_subcutaneous_db.py collect + --setup-plan
    — 15 collected, assert_keyless_projection resolves in that lane
  • E2E lanes re-run by the controller (not booted by the implementer)

AndresL230and others added 2 commits August 23, 2026 04:02
The #537 client grades every question through POST
/attempts/{id}/answer and has sent `include_answer_key: false` on
every /api/quiz/generate call since it shipped (frontend/src/lib/quiz/api.ts,
tested in api.test.ts and modeled by the e2e quiz specs), so the
generate response's per-option answer key is dead weight for the
real caller. Flip GenerateQuizBody.include_answer_key's default to
false; the field itself stays accepted-but-logged for one release
(explicit `include_answer_key: true` still returns `correct` booleans
and logs the #546 deprecation breadcrumb) rather than being deleted
outright.
_INTERNAL_QUESTION_KEYS (provenance, question_hash) already stripped
in both flag states, unaffected by this change.
Tests:
- test_quiz_answers_c.py::TestIncludeAnswerKey: rewritten for the new
default (test_default_now_strips_the_key asserts the omitted-flag
path is keyless and does NOT log; test_explicit_true_keeps_the_key_and_logs
covers the still-accepted opt-in path) + a new hermetic test
(test_default_response_is_the_keyless_projection_of_the_real_stored_key)
that grounds the "no leak" claim against the server's own
_strip_answer_key projection of the real encrypted questions_json,
plus a generic recursive key-name walk, rather than just checking
`correct` is absent.
- test_quiz_routes.py::test_returns_agent_output_in_legacy_wire_shape:
now opts in explicitly (`include_answer_key: true`) since it pins
the full keyed wire shape, which is no longer the default.
- tests/integration/test_quiz_subcutaneous_db.py (#545): added the
real-Postgres twin of the hermetic grounding test above
(test_generate_default_response_never_reveals_the_correct_option),
marked integration; unrun here (needs the local stack + function
mode), collection verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…view r1)
Fix round 1 on the task-3 review (task-3-findings-r1.md):
Important 1 — the recursive key-name walk in both the hermetic test
(test_quiz_answers_c.py) and its subcutaneous DB twin
(test_quiz_subcutaneous_db.py) only covered `{"questions": served}`,
missing generate's five other top-level fields (quiz_id,
requested_difficulty, resolved_difficulty, requested_count,
delivered_count). A future sibling key (e.g. `answer_key`) would have
sailed past it. Both now walk the full response body.
Important 2 — `served == _strip_answer_key(stored)` compared the
route's default-path output against the SAME function that produced
it, so it could not catch a leak introduced inside that projection
(or an accidental widening of `_KEYLESS_*_KEYS`) — falsifiable by
widening the allowlist while both sides still move together. Replaced
with a non-circular anchor: each served question's key set must be a
subset of, and each option's key set must equal exactly, a HARD-CODED
literal written in the test (not imported from routes.quiz). Verified
this actually discriminates by temporarily reverting the model
default to True and confirming both tests fail with "unexpected
key(s): {'explanation'}" before restoring it. Docstrings on both
tests rewritten to stop overclaiming ("any leak... shows up as a
diff") and describe the key-name walk as a heuristic backstop, not a
proof.
M4 — test_quiz_subcutaneous_db.py's new test now guards
`_attempt_row(...)` with an `is not None` assertion before
subscripting, matching the neighbouring test's pattern.
M6 — both caplog assertions in TestIncludeAnswerKey now filter
`caplog.records` by `rec.name == "routes.quiz"` before scanning for
the deprecation breadcrumb, since caplog's handler captures every
propagating logger.
M3 (frontend/src/lib/quiz/api.ts:32-33 now overstates #546's tonight
scope) is note-only per the reviewer; not fixed here — noted in the
task report and PR body.
Covering tests: tests/test_quiz_answers_c.py (16 -> unchanged count,
docstrings/assertions only) + tests/test_quiz_provenance_e5_e6.py —
40 passed. tests/integration/test_quiz_subcutaneous_db.py collect-only
verified (needs the real stack to execute). Full hermetic suite:
2225 passed, 80 skipped (unchanged from before this fix round).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Aug 23, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 2 minutes.

View limit details

Limit details: You’ve used the included review currently available.

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

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6158ec31-9490-4a9b-8096-f6f08a7d8272

📥 Commits

Reviewing files that changed from the base of the PR and between 9f34454 and ac13c62.

📒 Files selected for processing (10)
  • backend/models/__init__.py
  • backend/routes/quiz.py
  • backend/services/events_service.py
  • backend/tests/conftest.py
  • backend/tests/integration/test_quiz_subcutaneous_db.py
  • backend/tests/test_event_capture_seams.py
  • backend/tests/test_quiz_answers_c.py
  • backend/tests/test_quiz_provenance_e5_e6.py
  • backend/tests/test_quiz_routes.py
  • frontend/src/lib/quiz/api.ts

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

❤️ Share

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

@cloudflare-workers-and-pages

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

Branch Preview URL
Aug 26 2026, 06:06 AM

@supabase

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

AndresL230and others added 2 commits August 26, 2026 02:03
…w r2)
The one-release grace window for `include_answer_key` was gated on a
`logger.info`: nothing rolls log lines up, and the breadcrumb only ever
fired on an explicit true, so the population the window actually exists
for — flag-unaware callers that OMIT the field and silently changed
response shape at the flip — had no telemetry at all (F1).
Distinguish the three populations by `model_fields_set` (was the field on
the wire?) rather than by value, and emit an events_service event per
population so the count lands in the #375 admin-analytics by_event_type
rollup with no schema or endpoint work (F4, #117 convention):
* explicit true -> quiz.answer_key_served (the count that must reach
zero before the parameter is deleted; keeps the log line too)
* omitted -> quiz.answer_key_flag_omitted (deletion is a no-op
for them, but they are the shape-change population)
* explicit false -> nothing; every shipped #537 client sends this on
every generate and would swamp the rollup
Two event types rather than one with a `flag` payload field because
by_event_type does not break payloads out: one type would show a single
number mixing the population that blocks deletion with the one that
doesn't.
Also canonicalizes the flag's lifecycle prose on the field declaration
(F14) — it was restated at six sites.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…review r2)
Test strength:
- Both "keyless projection of the real stored key" tests asserted the
served shape but never that it WAS the projection: add count equality
vs the decrypted stored questions, per-index id/stem/option label+text
correspondence, and delivered_count computed off the projected list
(F2, F3).
- The ~30-line leak check (allowlist + key-set loops + recursive walk)
was duplicated across the hermetic and subcutaneous lanes and had
already drifted; it moves to one `assert_keyless_projection` fixture in
tests/conftest.py, which both lanes use (F11). Sharing a test-side
helper is not the circularity the anchor guards against — the
allowlists are still literals, never imported from routes.quiz.
- Option key drift now reports the symmetric difference, so a MISSING key
reports itself instead of only extras (F9).
- Pin the exact top-level response key set (F12).
- Consolidate the default-{} and explicit-false cases via parametrize,
keeping the caplog-silence assert (F13).
- Cover the three telemetry populations, including the no-event case.
Comments:
- test_quiz_routes: the keyed shape is not "what submit_quiz expects" —
submit grades from stored questions_json and reads only
question_id/selected_label (F5).
- test_quiz_answers_c: the #537 client has ALWAYS sent explicit false;
the omitting population is flag-unaware callers (F6).
- test_quiz_provenance: the keyed branch is no longer the default — this
PR is the flip (F7).
- frontend quiz/api.ts: the default is false now, so the client's
explicit false is belt-and-braces, not load-bearing (F8).
- Fix the vacuity-guard wording: the anchors sit below, not above (F10).
- Parse the subcutaneous response once (F15).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 merged commit 025474a into mainAug 26, 2026
8 checks passed
AndresL230 added a commit that referenced this pull request Aug 26, 2026
Brings in the include_answer_key default flip (#590), the dead
effective_explanations deletion (#587) and the CI port preflight (#588).
Clean auto-merge: main's routes/quiz.py hunks are in the generate
handler's response projection, this branch's are in the shared
gather/hoist above it.
AndresL230 added a commit that referenced this pull request Aug 28, 2026
Two conflicts, both in the files #590 and this branch both edited.
models/__init__.py: #590 flipped `include_answer_key`'s default to false and
replaced its comment with the canonical lifecycle account; this branch added
`source_attempt_id` / `missed_question_hashes` and the validator that ties
them together. Kept both — the flipped default and its prose verbatim, the
G5 fields ahead of it, the validator after.
routes/quiz.py: this branch relocated the agent-call except ladder out of
`generate_quiz` into `_generate_or_502` so the re-serve branch can catch a
failed top-up and still serve what it recovered; main (#592) added
`has_graph=True` to the same call. Kept the relocation and carried the new
argument (and its rationale) into the helper. #590's keyless projection,
#591's attempt helpers and #592's hoisted lookups merged cleanly and are
untouched — re-served questions still flow through the single
`_client_questions` call, which strips `question_hash`/`provenance` on both
the keyless and the opt-in keyed branch.
Two G5 tests were written against the pre-flip default and are re-pointed:
the keyed-projection test now opts in explicitly, and the keyless one adopts
main's shared `assert_keyless_projection` fixture, which grounds the check in
the answer key actually stored for the attempt. That fixture then caught G5's
conditional top-level `source` block, so the shared key literal now names
optional fields instead of requiring every response to carry them. The F5
assertions narrow to `quiz_reserve.missed_questions`: #592 makes an unrelated
reporter fire on the same request under these mocks.
Co-Authored-By: Claude Opus 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.

1 participant

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

feat(quiz): flip include_answer_key default to false (#546) - #590

Merged
AndresL230 merged 4 commits into
mainfrom
feat/546-answer-key-default-off
Aug 26, 2026
Merged

feat(quiz): flip include_answer_key default to false (#546)#590
AndresL230 merged 4 commits into
mainfrom
feat/546-answer-key-default-off

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

The #537 client grades every question through POST /attempts/{id}/answer
and already sends include_answer_key: false on every /api/quiz/generate
call (frontend/src/lib/quiz/api.ts, pinned by api.test.ts and the e2e
quiz specs), so the generate response's per-option answer key is dead weight
for the real caller. This flips GenerateQuizBody.include_answer_key's
default from true to false.

The parameter itself is not deleted — an explicit
include_answer_key: true still returns the full keyed shape and still logs
the #546 deprecation breadcrumb, kept accepted-but-logged for one release
per the plan. _INTERNAL_QUESTION_KEYS (provenance, question_hash) was
already stripped in both flag states and is unchanged.

Key table (both flag states) and the frontend/e2e audit (no bug found — every
real caller already sends the flag explicitly) are in the task report.

Frontend note (no code change here):frontend/src/lib/quiz/api.ts:32-33's
comment currently says "Removing the flag entirely is #546" — true of the
issue eventually, but this PR only flips the default; the comment now
overstates what landed tonight. Left as-is (out of this task's scope) — worth
a one-line tidy whenever the follow-up PR deletes the parameter.

Test plan

  • venv/bin/python -m pytest tests/ -q — 2225 passed, 80 skipped (was
    2223/79 on main; +1 new hermetic test passing, +1 new integration test
    cleanly skipped without RUN_INTEGRATION=1)
  • RED confirmed before test updates (2 failures from the default flip
    alone), and RED confirmed for the new grounded test specifically (by
    temporarily reverting the default and rerunning) — see report for both
    transcripts
  • venv/bin/ruff check — clean on all changed files
  • Browser/E2E lane (frontend/e2e/quiz*.spec.ts + oracles) — not run by
    the implementer per contract; controller's lane run
  • tests/integration/test_quiz_subcutaneous_db.py (new test included) —
    needs RUN_INTEGRATION=1 + local stack + function mode; collection
    verified only, controller's lane run

Refs #546

Lane runs (overnight 2026-08-23, local stack under the flock, function mode)

  • ruff ✅ · hermetic pytest ✅ 2225 passed / 80 skipped · eslint ✅ · tsc ✅ · vitest ✅
  • Playwright Chapter 1: 72 passed / 1 skipped / 1 failed → classified flake: gradebook.spec.ts:35 ("a course taken in two terms opens the right enrollment from each chip", "Exams" not visible after 7.8 s). Unrelated to this diff (quiz model default + comments + tests); the same spec passed in two other lanes on the same base tonight and 6/6 on a ×3 re-run of this branch. Worth a look for the test(e2e): browser-lane stability gate — 20 consecutive green runs #388 zero-flake gate.
  • oracles ✅ clean · integration ✅ 72 passed — includes the new subcutaneous no-leak test (tests/integration/test_quiz_subcutaneous_db.py), so the full-response walk + hard-coded key-set allowlist ran against real HTTP under the seam.
  • CI browser lane dispatched on this branch: https://github.com/SaplingLearn/Sapling/actions/runs/32629407089
  • Review: task review (2 Important on the no-leak test — walk scoped to questions only, grounding assertion circular through _strip_answer_key → both fixed) + scoped re-review clean.

Merge-gate review (2026-08-26)

/code-review at the merge gate: the flip itself reviewed safe for every
in-repo caller. All 15 findings were telemetry, test strength, or stale
comments; all are fixed in c412db8a + ac13c627.

Deprecation telemetry, made countable (F1, F4)

The grace window was gated on a logger.info — nothing rolls log lines up,
and the breadcrumb only ever fired on an explicit true, so the population
the window actually exists for (callers that OMIT the flag, whose response
shape silently changed at the flip) had zero telemetry. The route now
tells the three populations apart by "include_answer_key" in body.model_fields_set — was the field on the wire at all — rather than by
its value, and emits an events_service event per population (#117), which
lands in the #375 admin-analytics by_event_type rollup with no schema or
endpoint work:

callerresponseeventwhat it means for the #546 deletion gate
include_answer_key: truekeyedquiz.answer_key_served (usage, payload {quiz_id})the gate. A straggler really received the answer key; the parameter cannot be deleted until this count is zero across a release. Keeps the existing log line too.
field omittedkeylessquiz.answer_key_flag_omitted (usage, payload {quiz_id})flag-unaware caller, already on the shape #546 ends at. Deletion is a no-op for them — but they are the population that changed shape at the flip, so they stay visible while the window is open.
include_answer_key: falsekeyless(none)every shipped #537 client, on every generate. An event here would be a row per quiz, swamping the rollup to say something already known.

Two event types rather than one carrying a flag payload field: the
by_event_type rollup does not break payloads out, so a single type would
surface one number mixing the population that blocks deletion with the one
that doesn't — exactly the distinction the gate needs. Both are added to
EVENT_TAXONOMY (+ the pin in test_event_capture_seams.py); no migration,
no agent/seam change, and the emit is fire-and-forget like quiz.started
25 lines above it.

Projection tests, grounded (F2, F3, F9, F11, F12)

Both "keyless projection of the real stored key" tests asserted the served
shape but never that it actually was the projection: they would have
passed on an empty questions list. They now assert count equality against
the decrypted stored questions, per-index id/stem/option label+text
correspondence, and delivered_count computed off the projected list. The
~30-line leak block (allowlist + key-set loops + recursive walk) was
duplicated across the hermetic and subcutaneous lanes and had already
drifted; it is now one assert_keyless_projection fixture in
backend/tests/conftest.py that both lanes use (the allowlists stay literals
written test-side, never imported from routes.quiz, so the non-circularity
argument is unchanged). Option key drift is reported as a symmetric
difference, so a missing key reports itself; the exact top-level response
key set is now pinned.

Comment sweep (F5–F8, F10, F13–F15)

Four docstrings asserted things that stopped being true at the flip (the
keyed shape is not "what submit_quiz expects" — submit grades from the
stored questions_json and consumes only question_id/selected_label; the
#537 client has always sent explicit false; the keyed branch is no longer
the default). The lifecycle prose was restated at six sites and is now
canonical on GenerateQuizBody.include_answer_key with the rest pointing at
it. The frontend/src/lib/quiz/api.ts comment noted-but-not-fixed above is
fixed here
(F8) — it claimed the default is true and framed the client's
explicit false as load-bearing; it is now belt-and-braces, and the comment
says why the client keeps sending it anyway (so it stays out of the
quiz.answer_key_flag_omitted count). Default-{} and explicit-false are
consolidated under one parametrize, keeping the caplog-silence assert.

Merge-gate verification

  • venv/bin/python -m pytest tests/ -q -p no:cacheprovider2229
    passed, 80 skipped
    (+4 telemetry tests vs. the run above)
  • RED evidence for the telemetry pair: 3 failures before the route
    change (quiz.answer_key_served / ..._flag_omitted absent from both
    the sink and EVENT_TAXONOMY), green after
  • venv/bin/ruff check . — All checks passed
  • npx tsc --noEmit (only a comment changed in api.ts) — clean
  • tests/integration/test_quiz_subcutaneous_db.py collect + --setup-plan
    — 15 collected, assert_keyless_projection resolves in that lane
  • E2E lanes re-run by the controller (not booted by the implementer)

AndresL230and others added 2 commits August 23, 2026 04:02
The #537 client grades every question through POST
/attempts/{id}/answer and has sent `include_answer_key: false` on
every /api/quiz/generate call since it shipped (frontend/src/lib/quiz/api.ts,
tested in api.test.ts and modeled by the e2e quiz specs), so the
generate response's per-option answer key is dead weight for the
real caller. Flip GenerateQuizBody.include_answer_key's default to
false; the field itself stays accepted-but-logged for one release
(explicit `include_answer_key: true` still returns `correct` booleans
and logs the #546 deprecation breadcrumb) rather than being deleted
outright.
_INTERNAL_QUESTION_KEYS (provenance, question_hash) already stripped
in both flag states, unaffected by this change.
Tests:
- test_quiz_answers_c.py::TestIncludeAnswerKey: rewritten for the new
default (test_default_now_strips_the_key asserts the omitted-flag
path is keyless and does NOT log; test_explicit_true_keeps_the_key_and_logs
covers the still-accepted opt-in path) + a new hermetic test
(test_default_response_is_the_keyless_projection_of_the_real_stored_key)
that grounds the "no leak" claim against the server's own
_strip_answer_key projection of the real encrypted questions_json,
plus a generic recursive key-name walk, rather than just checking
`correct` is absent.
- test_quiz_routes.py::test_returns_agent_output_in_legacy_wire_shape:
now opts in explicitly (`include_answer_key: true`) since it pins
the full keyed wire shape, which is no longer the default.
- tests/integration/test_quiz_subcutaneous_db.py (#545): added the
real-Postgres twin of the hermetic grounding test above
(test_generate_default_response_never_reveals_the_correct_option),
marked integration; unrun here (needs the local stack + function
mode), collection verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…view r1)
Fix round 1 on the task-3 review (task-3-findings-r1.md):
Important 1 — the recursive key-name walk in both the hermetic test
(test_quiz_answers_c.py) and its subcutaneous DB twin
(test_quiz_subcutaneous_db.py) only covered `{"questions": served}`,
missing generate's five other top-level fields (quiz_id,
requested_difficulty, resolved_difficulty, requested_count,
delivered_count). A future sibling key (e.g. `answer_key`) would have
sailed past it. Both now walk the full response body.
Important 2 — `served == _strip_answer_key(stored)` compared the
route's default-path output against the SAME function that produced
it, so it could not catch a leak introduced inside that projection
(or an accidental widening of `_KEYLESS_*_KEYS`) — falsifiable by
widening the allowlist while both sides still move together. Replaced
with a non-circular anchor: each served question's key set must be a
subset of, and each option's key set must equal exactly, a HARD-CODED
literal written in the test (not imported from routes.quiz). Verified
this actually discriminates by temporarily reverting the model
default to True and confirming both tests fail with "unexpected
key(s): {'explanation'}" before restoring it. Docstrings on both
tests rewritten to stop overclaiming ("any leak... shows up as a
diff") and describe the key-name walk as a heuristic backstop, not a
proof.
M4 — test_quiz_subcutaneous_db.py's new test now guards
`_attempt_row(...)` with an `is not None` assertion before
subscripting, matching the neighbouring test's pattern.
M6 — both caplog assertions in TestIncludeAnswerKey now filter
`caplog.records` by `rec.name == "routes.quiz"` before scanning for
the deprecation breadcrumb, since caplog's handler captures every
propagating logger.
M3 (frontend/src/lib/quiz/api.ts:32-33 now overstates #546's tonight
scope) is note-only per the reviewer; not fixed here — noted in the
task report and PR body.
Covering tests: tests/test_quiz_answers_c.py (16 -> unchanged count,
docstrings/assertions only) + tests/test_quiz_provenance_e5_e6.py —
40 passed. tests/integration/test_quiz_subcutaneous_db.py collect-only
verified (needs the real stack to execute). Full hermetic suite:
2225 passed, 80 skipped (unchanged from before this fix round).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Aug 23, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 2 minutes.

View limit details

Limit details: You’ve used the included review currently available.

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

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6158ec31-9490-4a9b-8096-f6f08a7d8272

📥 Commits

Reviewing files that changed from the base of the PR and between 9f34454 and ac13c62.

📒 Files selected for processing (10)
  • backend/models/__init__.py
  • backend/routes/quiz.py
  • backend/services/events_service.py
  • backend/tests/conftest.py
  • backend/tests/integration/test_quiz_subcutaneous_db.py
  • backend/tests/test_event_capture_seams.py
  • backend/tests/test_quiz_answers_c.py
  • backend/tests/test_quiz_provenance_e5_e6.py
  • backend/tests/test_quiz_routes.py
  • frontend/src/lib/quiz/api.ts

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

❤️ Share

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

@cloudflare-workers-and-pages

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

Branch Preview URL
Aug 26 2026, 06:06 AM

@supabase

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

AndresL230and others added 2 commits August 26, 2026 02:03
…w r2)
The one-release grace window for `include_answer_key` was gated on a
`logger.info`: nothing rolls log lines up, and the breadcrumb only ever
fired on an explicit true, so the population the window actually exists
for — flag-unaware callers that OMIT the field and silently changed
response shape at the flip — had no telemetry at all (F1).
Distinguish the three populations by `model_fields_set` (was the field on
the wire?) rather than by value, and emit an events_service event per
population so the count lands in the #375 admin-analytics by_event_type
rollup with no schema or endpoint work (F4, #117 convention):
* explicit true -> quiz.answer_key_served (the count that must reach
zero before the parameter is deleted; keeps the log line too)
* omitted -> quiz.answer_key_flag_omitted (deletion is a no-op
for them, but they are the shape-change population)
* explicit false -> nothing; every shipped #537 client sends this on
every generate and would swamp the rollup
Two event types rather than one with a `flag` payload field because
by_event_type does not break payloads out: one type would show a single
number mixing the population that blocks deletion with the one that
doesn't.
Also canonicalizes the flag's lifecycle prose on the field declaration
(F14) — it was restated at six sites.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…review r2)
Test strength:
- Both "keyless projection of the real stored key" tests asserted the
served shape but never that it WAS the projection: add count equality
vs the decrypted stored questions, per-index id/stem/option label+text
correspondence, and delivered_count computed off the projected list
(F2, F3).
- The ~30-line leak check (allowlist + key-set loops + recursive walk)
was duplicated across the hermetic and subcutaneous lanes and had
already drifted; it moves to one `assert_keyless_projection` fixture in
tests/conftest.py, which both lanes use (F11). Sharing a test-side
helper is not the circularity the anchor guards against — the
allowlists are still literals, never imported from routes.quiz.
- Option key drift now reports the symmetric difference, so a MISSING key
reports itself instead of only extras (F9).
- Pin the exact top-level response key set (F12).
- Consolidate the default-{} and explicit-false cases via parametrize,
keeping the caplog-silence assert (F13).
- Cover the three telemetry populations, including the no-event case.
Comments:
- test_quiz_routes: the keyed shape is not "what submit_quiz expects" —
submit grades from stored questions_json and reads only
question_id/selected_label (F5).
- test_quiz_answers_c: the #537 client has ALWAYS sent explicit false;
the omitting population is flag-unaware callers (F6).
- test_quiz_provenance: the keyed branch is no longer the default — this
PR is the flip (F7).
- frontend quiz/api.ts: the default is false now, so the client's
explicit false is belt-and-braces, not load-bearing (F8).
- Fix the vacuity-guard wording: the anchors sit below, not above (F10).
- Parse the subcutaneous response once (F15).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 merged commit 025474a into mainAug 26, 2026
8 checks passed
AndresL230 added a commit that referenced this pull request Aug 26, 2026
Brings in the include_answer_key default flip (#590), the dead
effective_explanations deletion (#587) and the CI port preflight (#588).
Clean auto-merge: main's routes/quiz.py hunks are in the generate
handler's response projection, this branch's are in the shared
gather/hoist above it.
AndresL230 added a commit that referenced this pull request Aug 28, 2026
Two conflicts, both in the files #590 and this branch both edited.
models/__init__.py: #590 flipped `include_answer_key`'s default to false and
replaced its comment with the canonical lifecycle account; this branch added
`source_attempt_id` / `missed_question_hashes` and the validator that ties
them together. Kept both — the flipped default and its prose verbatim, the
G5 fields ahead of it, the validator after.
routes/quiz.py: this branch relocated the agent-call except ladder out of
`generate_quiz` into `_generate_or_502` so the re-serve branch can catch a
failed top-up and still serve what it recovered; main (#592) added
`has_graph=True` to the same call. Kept the relocation and carried the new
argument (and its rationale) into the helper. #590's keyless projection,
#591's attempt helpers and #592's hoisted lookups merged cleanly and are
untouched — re-served questions still flow through the single
`_client_questions` call, which strips `question_hash`/`provenance` on both
the keyless and the opt-in keyed branch.
Two G5 tests were written against the pre-flip default and are re-pointed:
the keyed-projection test now opts in explicitly, and the keyless one adopts
main's shared `assert_keyless_projection` fixture, which grounds the check in
the answer key actually stored for the attempt. That fixture then caught G5's
conditional top-level `source` block, so the shared key literal now names
optional fields instead of requiring every response to carry them. The F5
assertions narrow to `quiz_reserve.missed_questions`: #592 makes an unrelated
reporter fire on the same request under these mocks.
Co-Authored-By: Claude Opus 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.

1 participant

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

feat(quiz): flip include_answer_key default to false (#546) - #590

Merged
AndresL230 merged 4 commits into
mainfrom
feat/546-answer-key-default-off
Aug 26, 2026
Merged

feat(quiz): flip include_answer_key default to false (#546)#590
AndresL230 merged 4 commits into
mainfrom
feat/546-answer-key-default-off

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

The #537 client grades every question through POST /attempts/{id}/answer
and already sends include_answer_key: false on every /api/quiz/generate
call (frontend/src/lib/quiz/api.ts, pinned by api.test.ts and the e2e
quiz specs), so the generate response's per-option answer key is dead weight
for the real caller. This flips GenerateQuizBody.include_answer_key's
default from true to false.

The parameter itself is not deleted — an explicit
include_answer_key: true still returns the full keyed shape and still logs
the #546 deprecation breadcrumb, kept accepted-but-logged for one release
per the plan. _INTERNAL_QUESTION_KEYS (provenance, question_hash) was
already stripped in both flag states and is unchanged.

Key table (both flag states) and the frontend/e2e audit (no bug found — every
real caller already sends the flag explicitly) are in the task report.

Frontend note (no code change here):frontend/src/lib/quiz/api.ts:32-33's
comment currently says "Removing the flag entirely is #546" — true of the
issue eventually, but this PR only flips the default; the comment now
overstates what landed tonight. Left as-is (out of this task's scope) — worth
a one-line tidy whenever the follow-up PR deletes the parameter.

Test plan

  • venv/bin/python -m pytest tests/ -q — 2225 passed, 80 skipped (was
    2223/79 on main; +1 new hermetic test passing, +1 new integration test
    cleanly skipped without RUN_INTEGRATION=1)
  • RED confirmed before test updates (2 failures from the default flip
    alone), and RED confirmed for the new grounded test specifically (by
    temporarily reverting the default and rerunning) — see report for both
    transcripts
  • venv/bin/ruff check — clean on all changed files
  • Browser/E2E lane (frontend/e2e/quiz*.spec.ts + oracles) — not run by
    the implementer per contract; controller's lane run
  • tests/integration/test_quiz_subcutaneous_db.py (new test included) —
    needs RUN_INTEGRATION=1 + local stack + function mode; collection
    verified only, controller's lane run

Refs #546

Lane runs (overnight 2026-08-23, local stack under the flock, function mode)

  • ruff ✅ · hermetic pytest ✅ 2225 passed / 80 skipped · eslint ✅ · tsc ✅ · vitest ✅
  • Playwright Chapter 1: 72 passed / 1 skipped / 1 failed → classified flake: gradebook.spec.ts:35 ("a course taken in two terms opens the right enrollment from each chip", "Exams" not visible after 7.8 s). Unrelated to this diff (quiz model default + comments + tests); the same spec passed in two other lanes on the same base tonight and 6/6 on a ×3 re-run of this branch. Worth a look for the test(e2e): browser-lane stability gate — 20 consecutive green runs #388 zero-flake gate.
  • oracles ✅ clean · integration ✅ 72 passed — includes the new subcutaneous no-leak test (tests/integration/test_quiz_subcutaneous_db.py), so the full-response walk + hard-coded key-set allowlist ran against real HTTP under the seam.
  • CI browser lane dispatched on this branch: https://github.com/SaplingLearn/Sapling/actions/runs/32629407089
  • Review: task review (2 Important on the no-leak test — walk scoped to questions only, grounding assertion circular through _strip_answer_key → both fixed) + scoped re-review clean.

Merge-gate review (2026-08-26)

/code-review at the merge gate: the flip itself reviewed safe for every
in-repo caller. All 15 findings were telemetry, test strength, or stale
comments; all are fixed in c412db8a + ac13c627.

Deprecation telemetry, made countable (F1, F4)

The grace window was gated on a logger.info — nothing rolls log lines up,
and the breadcrumb only ever fired on an explicit true, so the population
the window actually exists for (callers that OMIT the flag, whose response
shape silently changed at the flip) had zero telemetry. The route now
tells the three populations apart by "include_answer_key" in body.model_fields_set — was the field on the wire at all — rather than by
its value, and emits an events_service event per population (#117), which
lands in the #375 admin-analytics by_event_type rollup with no schema or
endpoint work:

callerresponseeventwhat it means for the #546 deletion gate
include_answer_key: truekeyedquiz.answer_key_served (usage, payload {quiz_id})the gate. A straggler really received the answer key; the parameter cannot be deleted until this count is zero across a release. Keeps the existing log line too.
field omittedkeylessquiz.answer_key_flag_omitted (usage, payload {quiz_id})flag-unaware caller, already on the shape #546 ends at. Deletion is a no-op for them — but they are the population that changed shape at the flip, so they stay visible while the window is open.
include_answer_key: falsekeyless(none)every shipped #537 client, on every generate. An event here would be a row per quiz, swamping the rollup to say something already known.

Two event types rather than one carrying a flag payload field: the
by_event_type rollup does not break payloads out, so a single type would
surface one number mixing the population that blocks deletion with the one
that doesn't — exactly the distinction the gate needs. Both are added to
EVENT_TAXONOMY (+ the pin in test_event_capture_seams.py); no migration,
no agent/seam change, and the emit is fire-and-forget like quiz.started
25 lines above it.

Projection tests, grounded (F2, F3, F9, F11, F12)

Both "keyless projection of the real stored key" tests asserted the served
shape but never that it actually was the projection: they would have
passed on an empty questions list. They now assert count equality against
the decrypted stored questions, per-index id/stem/option label+text
correspondence, and delivered_count computed off the projected list. The
~30-line leak block (allowlist + key-set loops + recursive walk) was
duplicated across the hermetic and subcutaneous lanes and had already
drifted; it is now one assert_keyless_projection fixture in
backend/tests/conftest.py that both lanes use (the allowlists stay literals
written test-side, never imported from routes.quiz, so the non-circularity
argument is unchanged). Option key drift is reported as a symmetric
difference, so a missing key reports itself; the exact top-level response
key set is now pinned.

Comment sweep (F5–F8, F10, F13–F15)

Four docstrings asserted things that stopped being true at the flip (the
keyed shape is not "what submit_quiz expects" — submit grades from the
stored questions_json and consumes only question_id/selected_label; the
#537 client has always sent explicit false; the keyed branch is no longer
the default). The lifecycle prose was restated at six sites and is now
canonical on GenerateQuizBody.include_answer_key with the rest pointing at
it. The frontend/src/lib/quiz/api.ts comment noted-but-not-fixed above is
fixed here
(F8) — it claimed the default is true and framed the client's
explicit false as load-bearing; it is now belt-and-braces, and the comment
says why the client keeps sending it anyway (so it stays out of the
quiz.answer_key_flag_omitted count). Default-{} and explicit-false are
consolidated under one parametrize, keeping the caplog-silence assert.

Merge-gate verification

  • venv/bin/python -m pytest tests/ -q -p no:cacheprovider2229
    passed, 80 skipped
    (+4 telemetry tests vs. the run above)
  • RED evidence for the telemetry pair: 3 failures before the route
    change (quiz.answer_key_served / ..._flag_omitted absent from both
    the sink and EVENT_TAXONOMY), green after
  • venv/bin/ruff check . — All checks passed
  • npx tsc --noEmit (only a comment changed in api.ts) — clean
  • tests/integration/test_quiz_subcutaneous_db.py collect + --setup-plan
    — 15 collected, assert_keyless_projection resolves in that lane
  • E2E lanes re-run by the controller (not booted by the implementer)

AndresL230and others added 2 commits August 23, 2026 04:02
The #537 client grades every question through POST
/attempts/{id}/answer and has sent `include_answer_key: false` on
every /api/quiz/generate call since it shipped (frontend/src/lib/quiz/api.ts,
tested in api.test.ts and modeled by the e2e quiz specs), so the
generate response's per-option answer key is dead weight for the
real caller. Flip GenerateQuizBody.include_answer_key's default to
false; the field itself stays accepted-but-logged for one release
(explicit `include_answer_key: true` still returns `correct` booleans
and logs the #546 deprecation breadcrumb) rather than being deleted
outright.
_INTERNAL_QUESTION_KEYS (provenance, question_hash) already stripped
in both flag states, unaffected by this change.
Tests:
- test_quiz_answers_c.py::TestIncludeAnswerKey: rewritten for the new
default (test_default_now_strips_the_key asserts the omitted-flag
path is keyless and does NOT log; test_explicit_true_keeps_the_key_and_logs
covers the still-accepted opt-in path) + a new hermetic test
(test_default_response_is_the_keyless_projection_of_the_real_stored_key)
that grounds the "no leak" claim against the server's own
_strip_answer_key projection of the real encrypted questions_json,
plus a generic recursive key-name walk, rather than just checking
`correct` is absent.
- test_quiz_routes.py::test_returns_agent_output_in_legacy_wire_shape:
now opts in explicitly (`include_answer_key: true`) since it pins
the full keyed wire shape, which is no longer the default.
- tests/integration/test_quiz_subcutaneous_db.py (#545): added the
real-Postgres twin of the hermetic grounding test above
(test_generate_default_response_never_reveals_the_correct_option),
marked integration; unrun here (needs the local stack + function
mode), collection verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…view r1)
Fix round 1 on the task-3 review (task-3-findings-r1.md):
Important 1 — the recursive key-name walk in both the hermetic test
(test_quiz_answers_c.py) and its subcutaneous DB twin
(test_quiz_subcutaneous_db.py) only covered `{"questions": served}`,
missing generate's five other top-level fields (quiz_id,
requested_difficulty, resolved_difficulty, requested_count,
delivered_count). A future sibling key (e.g. `answer_key`) would have
sailed past it. Both now walk the full response body.
Important 2 — `served == _strip_answer_key(stored)` compared the
route's default-path output against the SAME function that produced
it, so it could not catch a leak introduced inside that projection
(or an accidental widening of `_KEYLESS_*_KEYS`) — falsifiable by
widening the allowlist while both sides still move together. Replaced
with a non-circular anchor: each served question's key set must be a
subset of, and each option's key set must equal exactly, a HARD-CODED
literal written in the test (not imported from routes.quiz). Verified
this actually discriminates by temporarily reverting the model
default to True and confirming both tests fail with "unexpected
key(s): {'explanation'}" before restoring it. Docstrings on both
tests rewritten to stop overclaiming ("any leak... shows up as a
diff") and describe the key-name walk as a heuristic backstop, not a
proof.
M4 — test_quiz_subcutaneous_db.py's new test now guards
`_attempt_row(...)` with an `is not None` assertion before
subscripting, matching the neighbouring test's pattern.
M6 — both caplog assertions in TestIncludeAnswerKey now filter
`caplog.records` by `rec.name == "routes.quiz"` before scanning for
the deprecation breadcrumb, since caplog's handler captures every
propagating logger.
M3 (frontend/src/lib/quiz/api.ts:32-33 now overstates #546's tonight
scope) is note-only per the reviewer; not fixed here — noted in the
task report and PR body.
Covering tests: tests/test_quiz_answers_c.py (16 -> unchanged count,
docstrings/assertions only) + tests/test_quiz_provenance_e5_e6.py —
40 passed. tests/integration/test_quiz_subcutaneous_db.py collect-only
verified (needs the real stack to execute). Full hermetic suite:
2225 passed, 80 skipped (unchanged from before this fix round).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Aug 23, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 2 minutes.

View limit details

Limit details: You’ve used the included review currently available.

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

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6158ec31-9490-4a9b-8096-f6f08a7d8272

📥 Commits

Reviewing files that changed from the base of the PR and between 9f34454 and ac13c62.

📒 Files selected for processing (10)
  • backend/models/__init__.py
  • backend/routes/quiz.py
  • backend/services/events_service.py
  • backend/tests/conftest.py
  • backend/tests/integration/test_quiz_subcutaneous_db.py
  • backend/tests/test_event_capture_seams.py
  • backend/tests/test_quiz_answers_c.py
  • backend/tests/test_quiz_provenance_e5_e6.py
  • backend/tests/test_quiz_routes.py
  • frontend/src/lib/quiz/api.ts

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

❤️ Share

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

@cloudflare-workers-and-pages

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

Branch Preview URL
Aug 26 2026, 06:06 AM

@supabase

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

AndresL230and others added 2 commits August 26, 2026 02:03
…w r2)
The one-release grace window for `include_answer_key` was gated on a
`logger.info`: nothing rolls log lines up, and the breadcrumb only ever
fired on an explicit true, so the population the window actually exists
for — flag-unaware callers that OMIT the field and silently changed
response shape at the flip — had no telemetry at all (F1).
Distinguish the three populations by `model_fields_set` (was the field on
the wire?) rather than by value, and emit an events_service event per
population so the count lands in the #375 admin-analytics by_event_type
rollup with no schema or endpoint work (F4, #117 convention):
* explicit true -> quiz.answer_key_served (the count that must reach
zero before the parameter is deleted; keeps the log line too)
* omitted -> quiz.answer_key_flag_omitted (deletion is a no-op
for them, but they are the shape-change population)
* explicit false -> nothing; every shipped #537 client sends this on
every generate and would swamp the rollup
Two event types rather than one with a `flag` payload field because
by_event_type does not break payloads out: one type would show a single
number mixing the population that blocks deletion with the one that
doesn't.
Also canonicalizes the flag's lifecycle prose on the field declaration
(F14) — it was restated at six sites.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…review r2)
Test strength:
- Both "keyless projection of the real stored key" tests asserted the
served shape but never that it WAS the projection: add count equality
vs the decrypted stored questions, per-index id/stem/option label+text
correspondence, and delivered_count computed off the projected list
(F2, F3).
- The ~30-line leak check (allowlist + key-set loops + recursive walk)
was duplicated across the hermetic and subcutaneous lanes and had
already drifted; it moves to one `assert_keyless_projection` fixture in
tests/conftest.py, which both lanes use (F11). Sharing a test-side
helper is not the circularity the anchor guards against — the
allowlists are still literals, never imported from routes.quiz.
- Option key drift now reports the symmetric difference, so a MISSING key
reports itself instead of only extras (F9).
- Pin the exact top-level response key set (F12).
- Consolidate the default-{} and explicit-false cases via parametrize,
keeping the caplog-silence assert (F13).
- Cover the three telemetry populations, including the no-event case.
Comments:
- test_quiz_routes: the keyed shape is not "what submit_quiz expects" —
submit grades from stored questions_json and reads only
question_id/selected_label (F5).
- test_quiz_answers_c: the #537 client has ALWAYS sent explicit false;
the omitting population is flag-unaware callers (F6).
- test_quiz_provenance: the keyed branch is no longer the default — this
PR is the flip (F7).
- frontend quiz/api.ts: the default is false now, so the client's
explicit false is belt-and-braces, not load-bearing (F8).
- Fix the vacuity-guard wording: the anchors sit below, not above (F10).
- Parse the subcutaneous response once (F15).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 merged commit 025474a into mainAug 26, 2026
8 checks passed
AndresL230 added a commit that referenced this pull request Aug 26, 2026
Brings in the include_answer_key default flip (#590), the dead
effective_explanations deletion (#587) and the CI port preflight (#588).
Clean auto-merge: main's routes/quiz.py hunks are in the generate
handler's response projection, this branch's are in the shared
gather/hoist above it.
AndresL230 added a commit that referenced this pull request Aug 28, 2026
Two conflicts, both in the files #590 and this branch both edited.
models/__init__.py: #590 flipped `include_answer_key`'s default to false and
replaced its comment with the canonical lifecycle account; this branch added
`source_attempt_id` / `missed_question_hashes` and the validator that ties
them together. Kept both — the flipped default and its prose verbatim, the
G5 fields ahead of it, the validator after.
routes/quiz.py: this branch relocated the agent-call except ladder out of
`generate_quiz` into `_generate_or_502` so the re-serve branch can catch a
failed top-up and still serve what it recovered; main (#592) added
`has_graph=True` to the same call. Kept the relocation and carried the new
argument (and its rationale) into the helper. #590's keyless projection,
#591's attempt helpers and #592's hoisted lookups merged cleanly and are
untouched — re-served questions still flow through the single
`_client_questions` call, which strips `question_hash`/`provenance` on both
the keyless and the opt-in keyed branch.
Two G5 tests were written against the pre-flip default and are re-pointed:
the keyed-projection test now opts in explicitly, and the keyless one adopts
main's shared `assert_keyless_projection` fixture, which grounds the check in
the answer key actually stored for the attempt. That fixture then caught G5's
conditional top-level `source` block, so the shared key literal now names
optional fields instead of requiring every response to carry them. The F5
assertions narrow to `quiz_reserve.missed_questions`: #592 makes an unrelated
reporter fire on the same request under these mocks.
Co-Authored-By: Claude Opus 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.

1 participant

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

feat(quiz): flip include_answer_key default to false (#546) - #590

Merged
AndresL230 merged 4 commits into
mainfrom
feat/546-answer-key-default-off
Aug 26, 2026
Merged

feat(quiz): flip include_answer_key default to false (#546)#590
AndresL230 merged 4 commits into
mainfrom
feat/546-answer-key-default-off

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

The #537 client grades every question through POST /attempts/{id}/answer
and already sends include_answer_key: false on every /api/quiz/generate
call (frontend/src/lib/quiz/api.ts, pinned by api.test.ts and the e2e
quiz specs), so the generate response's per-option answer key is dead weight
for the real caller. This flips GenerateQuizBody.include_answer_key's
default from true to false.

The parameter itself is not deleted — an explicit
include_answer_key: true still returns the full keyed shape and still logs
the #546 deprecation breadcrumb, kept accepted-but-logged for one release
per the plan. _INTERNAL_QUESTION_KEYS (provenance, question_hash) was
already stripped in both flag states and is unchanged.

Key table (both flag states) and the frontend/e2e audit (no bug found — every
real caller already sends the flag explicitly) are in the task report.

Frontend note (no code change here):frontend/src/lib/quiz/api.ts:32-33's
comment currently says "Removing the flag entirely is #546" — true of the
issue eventually, but this PR only flips the default; the comment now
overstates what landed tonight. Left as-is (out of this task's scope) — worth
a one-line tidy whenever the follow-up PR deletes the parameter.

Test plan

  • venv/bin/python -m pytest tests/ -q — 2225 passed, 80 skipped (was
    2223/79 on main; +1 new hermetic test passing, +1 new integration test
    cleanly skipped without RUN_INTEGRATION=1)
  • RED confirmed before test updates (2 failures from the default flip
    alone), and RED confirmed for the new grounded test specifically (by
    temporarily reverting the default and rerunning) — see report for both
    transcripts
  • venv/bin/ruff check — clean on all changed files
  • Browser/E2E lane (frontend/e2e/quiz*.spec.ts + oracles) — not run by
    the implementer per contract; controller's lane run
  • tests/integration/test_quiz_subcutaneous_db.py (new test included) —
    needs RUN_INTEGRATION=1 + local stack + function mode; collection
    verified only, controller's lane run

Refs #546

Lane runs (overnight 2026-08-23, local stack under the flock, function mode)

  • ruff ✅ · hermetic pytest ✅ 2225 passed / 80 skipped · eslint ✅ · tsc ✅ · vitest ✅
  • Playwright Chapter 1: 72 passed / 1 skipped / 1 failed → classified flake: gradebook.spec.ts:35 ("a course taken in two terms opens the right enrollment from each chip", "Exams" not visible after 7.8 s). Unrelated to this diff (quiz model default + comments + tests); the same spec passed in two other lanes on the same base tonight and 6/6 on a ×3 re-run of this branch. Worth a look for the test(e2e): browser-lane stability gate — 20 consecutive green runs #388 zero-flake gate.
  • oracles ✅ clean · integration ✅ 72 passed — includes the new subcutaneous no-leak test (tests/integration/test_quiz_subcutaneous_db.py), so the full-response walk + hard-coded key-set allowlist ran against real HTTP under the seam.
  • CI browser lane dispatched on this branch: https://github.com/SaplingLearn/Sapling/actions/runs/32629407089
  • Review: task review (2 Important on the no-leak test — walk scoped to questions only, grounding assertion circular through _strip_answer_key → both fixed) + scoped re-review clean.

Merge-gate review (2026-08-26)

/code-review at the merge gate: the flip itself reviewed safe for every
in-repo caller. All 15 findings were telemetry, test strength, or stale
comments; all are fixed in c412db8a + ac13c627.

Deprecation telemetry, made countable (F1, F4)

The grace window was gated on a logger.info — nothing rolls log lines up,
and the breadcrumb only ever fired on an explicit true, so the population
the window actually exists for (callers that OMIT the flag, whose response
shape silently changed at the flip) had zero telemetry. The route now
tells the three populations apart by "include_answer_key" in body.model_fields_set — was the field on the wire at all — rather than by
its value, and emits an events_service event per population (#117), which
lands in the #375 admin-analytics by_event_type rollup with no schema or
endpoint work:

callerresponseeventwhat it means for the #546 deletion gate
include_answer_key: truekeyedquiz.answer_key_served (usage, payload {quiz_id})the gate. A straggler really received the answer key; the parameter cannot be deleted until this count is zero across a release. Keeps the existing log line too.
field omittedkeylessquiz.answer_key_flag_omitted (usage, payload {quiz_id})flag-unaware caller, already on the shape #546 ends at. Deletion is a no-op for them — but they are the population that changed shape at the flip, so they stay visible while the window is open.
include_answer_key: falsekeyless(none)every shipped #537 client, on every generate. An event here would be a row per quiz, swamping the rollup to say something already known.

Two event types rather than one carrying a flag payload field: the
by_event_type rollup does not break payloads out, so a single type would
surface one number mixing the population that blocks deletion with the one
that doesn't — exactly the distinction the gate needs. Both are added to
EVENT_TAXONOMY (+ the pin in test_event_capture_seams.py); no migration,
no agent/seam change, and the emit is fire-and-forget like quiz.started
25 lines above it.

Projection tests, grounded (F2, F3, F9, F11, F12)

Both "keyless projection of the real stored key" tests asserted the served
shape but never that it actually was the projection: they would have
passed on an empty questions list. They now assert count equality against
the decrypted stored questions, per-index id/stem/option label+text
correspondence, and delivered_count computed off the projected list. The
~30-line leak block (allowlist + key-set loops + recursive walk) was
duplicated across the hermetic and subcutaneous lanes and had already
drifted; it is now one assert_keyless_projection fixture in
backend/tests/conftest.py that both lanes use (the allowlists stay literals
written test-side, never imported from routes.quiz, so the non-circularity
argument is unchanged). Option key drift is reported as a symmetric
difference, so a missing key reports itself; the exact top-level response
key set is now pinned.

Comment sweep (F5–F8, F10, F13–F15)

Four docstrings asserted things that stopped being true at the flip (the
keyed shape is not "what submit_quiz expects" — submit grades from the
stored questions_json and consumes only question_id/selected_label; the
#537 client has always sent explicit false; the keyed branch is no longer
the default). The lifecycle prose was restated at six sites and is now
canonical on GenerateQuizBody.include_answer_key with the rest pointing at
it. The frontend/src/lib/quiz/api.ts comment noted-but-not-fixed above is
fixed here
(F8) — it claimed the default is true and framed the client's
explicit false as load-bearing; it is now belt-and-braces, and the comment
says why the client keeps sending it anyway (so it stays out of the
quiz.answer_key_flag_omitted count). Default-{} and explicit-false are
consolidated under one parametrize, keeping the caplog-silence assert.

Merge-gate verification

  • venv/bin/python -m pytest tests/ -q -p no:cacheprovider2229
    passed, 80 skipped
    (+4 telemetry tests vs. the run above)
  • RED evidence for the telemetry pair: 3 failures before the route
    change (quiz.answer_key_served / ..._flag_omitted absent from both
    the sink and EVENT_TAXONOMY), green after
  • venv/bin/ruff check . — All checks passed
  • npx tsc --noEmit (only a comment changed in api.ts) — clean
  • tests/integration/test_quiz_subcutaneous_db.py collect + --setup-plan
    — 15 collected, assert_keyless_projection resolves in that lane
  • E2E lanes re-run by the controller (not booted by the implementer)

AndresL230and others added 2 commits August 23, 2026 04:02
The #537 client grades every question through POST
/attempts/{id}/answer and has sent `include_answer_key: false` on
every /api/quiz/generate call since it shipped (frontend/src/lib/quiz/api.ts,
tested in api.test.ts and modeled by the e2e quiz specs), so the
generate response's per-option answer key is dead weight for the
real caller. Flip GenerateQuizBody.include_answer_key's default to
false; the field itself stays accepted-but-logged for one release
(explicit `include_answer_key: true` still returns `correct` booleans
and logs the #546 deprecation breadcrumb) rather than being deleted
outright.
_INTERNAL_QUESTION_KEYS (provenance, question_hash) already stripped
in both flag states, unaffected by this change.
Tests:
- test_quiz_answers_c.py::TestIncludeAnswerKey: rewritten for the new
default (test_default_now_strips_the_key asserts the omitted-flag
path is keyless and does NOT log; test_explicit_true_keeps_the_key_and_logs
covers the still-accepted opt-in path) + a new hermetic test
(test_default_response_is_the_keyless_projection_of_the_real_stored_key)
that grounds the "no leak" claim against the server's own
_strip_answer_key projection of the real encrypted questions_json,
plus a generic recursive key-name walk, rather than just checking
`correct` is absent.
- test_quiz_routes.py::test_returns_agent_output_in_legacy_wire_shape:
now opts in explicitly (`include_answer_key: true`) since it pins
the full keyed wire shape, which is no longer the default.
- tests/integration/test_quiz_subcutaneous_db.py (#545): added the
real-Postgres twin of the hermetic grounding test above
(test_generate_default_response_never_reveals_the_correct_option),
marked integration; unrun here (needs the local stack + function
mode), collection verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…view r1)
Fix round 1 on the task-3 review (task-3-findings-r1.md):
Important 1 — the recursive key-name walk in both the hermetic test
(test_quiz_answers_c.py) and its subcutaneous DB twin
(test_quiz_subcutaneous_db.py) only covered `{"questions": served}`,
missing generate's five other top-level fields (quiz_id,
requested_difficulty, resolved_difficulty, requested_count,
delivered_count). A future sibling key (e.g. `answer_key`) would have
sailed past it. Both now walk the full response body.
Important 2 — `served == _strip_answer_key(stored)` compared the
route's default-path output against the SAME function that produced
it, so it could not catch a leak introduced inside that projection
(or an accidental widening of `_KEYLESS_*_KEYS`) — falsifiable by
widening the allowlist while both sides still move together. Replaced
with a non-circular anchor: each served question's key set must be a
subset of, and each option's key set must equal exactly, a HARD-CODED
literal written in the test (not imported from routes.quiz). Verified
this actually discriminates by temporarily reverting the model
default to True and confirming both tests fail with "unexpected
key(s): {'explanation'}" before restoring it. Docstrings on both
tests rewritten to stop overclaiming ("any leak... shows up as a
diff") and describe the key-name walk as a heuristic backstop, not a
proof.
M4 — test_quiz_subcutaneous_db.py's new test now guards
`_attempt_row(...)` with an `is not None` assertion before
subscripting, matching the neighbouring test's pattern.
M6 — both caplog assertions in TestIncludeAnswerKey now filter
`caplog.records` by `rec.name == "routes.quiz"` before scanning for
the deprecation breadcrumb, since caplog's handler captures every
propagating logger.
M3 (frontend/src/lib/quiz/api.ts:32-33 now overstates #546's tonight
scope) is note-only per the reviewer; not fixed here — noted in the
task report and PR body.
Covering tests: tests/test_quiz_answers_c.py (16 -> unchanged count,
docstrings/assertions only) + tests/test_quiz_provenance_e5_e6.py —
40 passed. tests/integration/test_quiz_subcutaneous_db.py collect-only
verified (needs the real stack to execute). Full hermetic suite:
2225 passed, 80 skipped (unchanged from before this fix round).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Aug 23, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 2 minutes.

View limit details

Limit details: You’ve used the included review currently available.

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

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6158ec31-9490-4a9b-8096-f6f08a7d8272

📥 Commits

Reviewing files that changed from the base of the PR and between 9f34454 and ac13c62.

📒 Files selected for processing (10)
  • backend/models/__init__.py
  • backend/routes/quiz.py
  • backend/services/events_service.py
  • backend/tests/conftest.py
  • backend/tests/integration/test_quiz_subcutaneous_db.py
  • backend/tests/test_event_capture_seams.py
  • backend/tests/test_quiz_answers_c.py
  • backend/tests/test_quiz_provenance_e5_e6.py
  • backend/tests/test_quiz_routes.py
  • frontend/src/lib/quiz/api.ts

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

❤️ Share

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

@cloudflare-workers-and-pages

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

Branch Preview URL
Aug 26 2026, 06:06 AM

@supabase

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

AndresL230and others added 2 commits August 26, 2026 02:03
…w r2)
The one-release grace window for `include_answer_key` was gated on a
`logger.info`: nothing rolls log lines up, and the breadcrumb only ever
fired on an explicit true, so the population the window actually exists
for — flag-unaware callers that OMIT the field and silently changed
response shape at the flip — had no telemetry at all (F1).
Distinguish the three populations by `model_fields_set` (was the field on
the wire?) rather than by value, and emit an events_service event per
population so the count lands in the #375 admin-analytics by_event_type
rollup with no schema or endpoint work (F4, #117 convention):
* explicit true -> quiz.answer_key_served (the count that must reach
zero before the parameter is deleted; keeps the log line too)
* omitted -> quiz.answer_key_flag_omitted (deletion is a no-op
for them, but they are the shape-change population)
* explicit false -> nothing; every shipped #537 client sends this on
every generate and would swamp the rollup
Two event types rather than one with a `flag` payload field because
by_event_type does not break payloads out: one type would show a single
number mixing the population that blocks deletion with the one that
doesn't.
Also canonicalizes the flag's lifecycle prose on the field declaration
(F14) — it was restated at six sites.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…review r2)
Test strength:
- Both "keyless projection of the real stored key" tests asserted the
served shape but never that it WAS the projection: add count equality
vs the decrypted stored questions, per-index id/stem/option label+text
correspondence, and delivered_count computed off the projected list
(F2, F3).
- The ~30-line leak check (allowlist + key-set loops + recursive walk)
was duplicated across the hermetic and subcutaneous lanes and had
already drifted; it moves to one `assert_keyless_projection` fixture in
tests/conftest.py, which both lanes use (F11). Sharing a test-side
helper is not the circularity the anchor guards against — the
allowlists are still literals, never imported from routes.quiz.
- Option key drift now reports the symmetric difference, so a MISSING key
reports itself instead of only extras (F9).
- Pin the exact top-level response key set (F12).
- Consolidate the default-{} and explicit-false cases via parametrize,
keeping the caplog-silence assert (F13).
- Cover the three telemetry populations, including the no-event case.
Comments:
- test_quiz_routes: the keyed shape is not "what submit_quiz expects" —
submit grades from stored questions_json and reads only
question_id/selected_label (F5).
- test_quiz_answers_c: the #537 client has ALWAYS sent explicit false;
the omitting population is flag-unaware callers (F6).
- test_quiz_provenance: the keyed branch is no longer the default — this
PR is the flip (F7).
- frontend quiz/api.ts: the default is false now, so the client's
explicit false is belt-and-braces, not load-bearing (F8).
- Fix the vacuity-guard wording: the anchors sit below, not above (F10).
- Parse the subcutaneous response once (F15).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 merged commit 025474a into mainAug 26, 2026
8 checks passed
AndresL230 added a commit that referenced this pull request Aug 26, 2026
Brings in the include_answer_key default flip (#590), the dead
effective_explanations deletion (#587) and the CI port preflight (#588).
Clean auto-merge: main's routes/quiz.py hunks are in the generate
handler's response projection, this branch's are in the shared
gather/hoist above it.
AndresL230 added a commit that referenced this pull request Aug 28, 2026
Two conflicts, both in the files #590 and this branch both edited.
models/__init__.py: #590 flipped `include_answer_key`'s default to false and
replaced its comment with the canonical lifecycle account; this branch added
`source_attempt_id` / `missed_question_hashes` and the validator that ties
them together. Kept both — the flipped default and its prose verbatim, the
G5 fields ahead of it, the validator after.
routes/quiz.py: this branch relocated the agent-call except ladder out of
`generate_quiz` into `_generate_or_502` so the re-serve branch can catch a
failed top-up and still serve what it recovered; main (#592) added
`has_graph=True` to the same call. Kept the relocation and carried the new
argument (and its rationale) into the helper. #590's keyless projection,
#591's attempt helpers and #592's hoisted lookups merged cleanly and are
untouched — re-served questions still flow through the single
`_client_questions` call, which strips `question_hash`/`provenance` on both
the keyless and the opt-in keyed branch.
Two G5 tests were written against the pre-flip default and are re-pointed:
the keyed-projection test now opts in explicitly, and the keyless one adopts
main's shared `assert_keyless_projection` fixture, which grounds the check in
the answer key actually stored for the attempt. That fixture then caught G5's
conditional top-level `source` block, so the shared key literal now names
optional fields instead of requiring every response to carry them. The F5
assertions narrow to `quiz_reserve.missed_questions`: #592 makes an unrelated
reporter fire on the same request under these mocks.
Co-Authored-By: Claude Opus 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.

1 participant

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

feat(quiz): flip include_answer_key default to false (#546) - #590

Merged
AndresL230 merged 4 commits into
mainfrom
feat/546-answer-key-default-off
Aug 26, 2026
Merged

feat(quiz): flip include_answer_key default to false (#546)#590
AndresL230 merged 4 commits into
mainfrom
feat/546-answer-key-default-off

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

The #537 client grades every question through POST /attempts/{id}/answer
and already sends include_answer_key: false on every /api/quiz/generate
call (frontend/src/lib/quiz/api.ts, pinned by api.test.ts and the e2e
quiz specs), so the generate response's per-option answer key is dead weight
for the real caller. This flips GenerateQuizBody.include_answer_key's
default from true to false.

The parameter itself is not deleted — an explicit
include_answer_key: true still returns the full keyed shape and still logs
the #546 deprecation breadcrumb, kept accepted-but-logged for one release
per the plan. _INTERNAL_QUESTION_KEYS (provenance, question_hash) was
already stripped in both flag states and is unchanged.

Key table (both flag states) and the frontend/e2e audit (no bug found — every
real caller already sends the flag explicitly) are in the task report.

Frontend note (no code change here):frontend/src/lib/quiz/api.ts:32-33's
comment currently says "Removing the flag entirely is #546" — true of the
issue eventually, but this PR only flips the default; the comment now
overstates what landed tonight. Left as-is (out of this task's scope) — worth
a one-line tidy whenever the follow-up PR deletes the parameter.

Test plan

  • venv/bin/python -m pytest tests/ -q — 2225 passed, 80 skipped (was
    2223/79 on main; +1 new hermetic test passing, +1 new integration test
    cleanly skipped without RUN_INTEGRATION=1)
  • RED confirmed before test updates (2 failures from the default flip
    alone), and RED confirmed for the new grounded test specifically (by
    temporarily reverting the default and rerunning) — see report for both
    transcripts
  • venv/bin/ruff check — clean on all changed files
  • Browser/E2E lane (frontend/e2e/quiz*.spec.ts + oracles) — not run by
    the implementer per contract; controller's lane run
  • tests/integration/test_quiz_subcutaneous_db.py (new test included) —
    needs RUN_INTEGRATION=1 + local stack + function mode; collection
    verified only, controller's lane run

Refs #546

Lane runs (overnight 2026-08-23, local stack under the flock, function mode)

  • ruff ✅ · hermetic pytest ✅ 2225 passed / 80 skipped · eslint ✅ · tsc ✅ · vitest ✅
  • Playwright Chapter 1: 72 passed / 1 skipped / 1 failed → classified flake: gradebook.spec.ts:35 ("a course taken in two terms opens the right enrollment from each chip", "Exams" not visible after 7.8 s). Unrelated to this diff (quiz model default + comments + tests); the same spec passed in two other lanes on the same base tonight and 6/6 on a ×3 re-run of this branch. Worth a look for the test(e2e): browser-lane stability gate — 20 consecutive green runs #388 zero-flake gate.
  • oracles ✅ clean · integration ✅ 72 passed — includes the new subcutaneous no-leak test (tests/integration/test_quiz_subcutaneous_db.py), so the full-response walk + hard-coded key-set allowlist ran against real HTTP under the seam.
  • CI browser lane dispatched on this branch: https://github.com/SaplingLearn/Sapling/actions/runs/32629407089
  • Review: task review (2 Important on the no-leak test — walk scoped to questions only, grounding assertion circular through _strip_answer_key → both fixed) + scoped re-review clean.

Merge-gate review (2026-08-26)

/code-review at the merge gate: the flip itself reviewed safe for every
in-repo caller. All 15 findings were telemetry, test strength, or stale
comments; all are fixed in c412db8a + ac13c627.

Deprecation telemetry, made countable (F1, F4)

The grace window was gated on a logger.info — nothing rolls log lines up,
and the breadcrumb only ever fired on an explicit true, so the population
the window actually exists for (callers that OMIT the flag, whose response
shape silently changed at the flip) had zero telemetry. The route now
tells the three populations apart by "include_answer_key" in body.model_fields_set — was the field on the wire at all — rather than by
its value, and emits an events_service event per population (#117), which
lands in the #375 admin-analytics by_event_type rollup with no schema or
endpoint work:

callerresponseeventwhat it means for the #546 deletion gate
include_answer_key: truekeyedquiz.answer_key_served (usage, payload {quiz_id})the gate. A straggler really received the answer key; the parameter cannot be deleted until this count is zero across a release. Keeps the existing log line too.
field omittedkeylessquiz.answer_key_flag_omitted (usage, payload {quiz_id})flag-unaware caller, already on the shape #546 ends at. Deletion is a no-op for them — but they are the population that changed shape at the flip, so they stay visible while the window is open.
include_answer_key: falsekeyless(none)every shipped #537 client, on every generate. An event here would be a row per quiz, swamping the rollup to say something already known.

Two event types rather than one carrying a flag payload field: the
by_event_type rollup does not break payloads out, so a single type would
surface one number mixing the population that blocks deletion with the one
that doesn't — exactly the distinction the gate needs. Both are added to
EVENT_TAXONOMY (+ the pin in test_event_capture_seams.py); no migration,
no agent/seam change, and the emit is fire-and-forget like quiz.started
25 lines above it.

Projection tests, grounded (F2, F3, F9, F11, F12)

Both "keyless projection of the real stored key" tests asserted the served
shape but never that it actually was the projection: they would have
passed on an empty questions list. They now assert count equality against
the decrypted stored questions, per-index id/stem/option label+text
correspondence, and delivered_count computed off the projected list. The
~30-line leak block (allowlist + key-set loops + recursive walk) was
duplicated across the hermetic and subcutaneous lanes and had already
drifted; it is now one assert_keyless_projection fixture in
backend/tests/conftest.py that both lanes use (the allowlists stay literals
written test-side, never imported from routes.quiz, so the non-circularity
argument is unchanged). Option key drift is reported as a symmetric
difference, so a missing key reports itself; the exact top-level response
key set is now pinned.

Comment sweep (F5–F8, F10, F13–F15)

Four docstrings asserted things that stopped being true at the flip (the
keyed shape is not "what submit_quiz expects" — submit grades from the
stored questions_json and consumes only question_id/selected_label; the
#537 client has always sent explicit false; the keyed branch is no longer
the default). The lifecycle prose was restated at six sites and is now
canonical on GenerateQuizBody.include_answer_key with the rest pointing at
it. The frontend/src/lib/quiz/api.ts comment noted-but-not-fixed above is
fixed here
(F8) — it claimed the default is true and framed the client's
explicit false as load-bearing; it is now belt-and-braces, and the comment
says why the client keeps sending it anyway (so it stays out of the
quiz.answer_key_flag_omitted count). Default-{} and explicit-false are
consolidated under one parametrize, keeping the caplog-silence assert.

Merge-gate verification

  • venv/bin/python -m pytest tests/ -q -p no:cacheprovider2229
    passed, 80 skipped
    (+4 telemetry tests vs. the run above)
  • RED evidence for the telemetry pair: 3 failures before the route
    change (quiz.answer_key_served / ..._flag_omitted absent from both
    the sink and EVENT_TAXONOMY), green after
  • venv/bin/ruff check . — All checks passed
  • npx tsc --noEmit (only a comment changed in api.ts) — clean
  • tests/integration/test_quiz_subcutaneous_db.py collect + --setup-plan
    — 15 collected, assert_keyless_projection resolves in that lane
  • E2E lanes re-run by the controller (not booted by the implementer)

AndresL230and others added 2 commits August 23, 2026 04:02
The #537 client grades every question through POST
/attempts/{id}/answer and has sent `include_answer_key: false` on
every /api/quiz/generate call since it shipped (frontend/src/lib/quiz/api.ts,
tested in api.test.ts and modeled by the e2e quiz specs), so the
generate response's per-option answer key is dead weight for the
real caller. Flip GenerateQuizBody.include_answer_key's default to
false; the field itself stays accepted-but-logged for one release
(explicit `include_answer_key: true` still returns `correct` booleans
and logs the #546 deprecation breadcrumb) rather than being deleted
outright.
_INTERNAL_QUESTION_KEYS (provenance, question_hash) already stripped
in both flag states, unaffected by this change.
Tests:
- test_quiz_answers_c.py::TestIncludeAnswerKey: rewritten for the new
default (test_default_now_strips_the_key asserts the omitted-flag
path is keyless and does NOT log; test_explicit_true_keeps_the_key_and_logs
covers the still-accepted opt-in path) + a new hermetic test
(test_default_response_is_the_keyless_projection_of_the_real_stored_key)
that grounds the "no leak" claim against the server's own
_strip_answer_key projection of the real encrypted questions_json,
plus a generic recursive key-name walk, rather than just checking
`correct` is absent.
- test_quiz_routes.py::test_returns_agent_output_in_legacy_wire_shape:
now opts in explicitly (`include_answer_key: true`) since it pins
the full keyed wire shape, which is no longer the default.
- tests/integration/test_quiz_subcutaneous_db.py (#545): added the
real-Postgres twin of the hermetic grounding test above
(test_generate_default_response_never_reveals_the_correct_option),
marked integration; unrun here (needs the local stack + function
mode), collection verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…view r1)
Fix round 1 on the task-3 review (task-3-findings-r1.md):
Important 1 — the recursive key-name walk in both the hermetic test
(test_quiz_answers_c.py) and its subcutaneous DB twin
(test_quiz_subcutaneous_db.py) only covered `{"questions": served}`,
missing generate's five other top-level fields (quiz_id,
requested_difficulty, resolved_difficulty, requested_count,
delivered_count). A future sibling key (e.g. `answer_key`) would have
sailed past it. Both now walk the full response body.
Important 2 — `served == _strip_answer_key(stored)` compared the
route's default-path output against the SAME function that produced
it, so it could not catch a leak introduced inside that projection
(or an accidental widening of `_KEYLESS_*_KEYS`) — falsifiable by
widening the allowlist while both sides still move together. Replaced
with a non-circular anchor: each served question's key set must be a
subset of, and each option's key set must equal exactly, a HARD-CODED
literal written in the test (not imported from routes.quiz). Verified
this actually discriminates by temporarily reverting the model
default to True and confirming both tests fail with "unexpected
key(s): {'explanation'}" before restoring it. Docstrings on both
tests rewritten to stop overclaiming ("any leak... shows up as a
diff") and describe the key-name walk as a heuristic backstop, not a
proof.
M4 — test_quiz_subcutaneous_db.py's new test now guards
`_attempt_row(...)` with an `is not None` assertion before
subscripting, matching the neighbouring test's pattern.
M6 — both caplog assertions in TestIncludeAnswerKey now filter
`caplog.records` by `rec.name == "routes.quiz"` before scanning for
the deprecation breadcrumb, since caplog's handler captures every
propagating logger.
M3 (frontend/src/lib/quiz/api.ts:32-33 now overstates #546's tonight
scope) is note-only per the reviewer; not fixed here — noted in the
task report and PR body.
Covering tests: tests/test_quiz_answers_c.py (16 -> unchanged count,
docstrings/assertions only) + tests/test_quiz_provenance_e5_e6.py —
40 passed. tests/integration/test_quiz_subcutaneous_db.py collect-only
verified (needs the real stack to execute). Full hermetic suite:
2225 passed, 80 skipped (unchanged from before this fix round).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Aug 23, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 2 minutes.

View limit details

Limit details: You’ve used the included review currently available.

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

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6158ec31-9490-4a9b-8096-f6f08a7d8272

📥 Commits

Reviewing files that changed from the base of the PR and between 9f34454 and ac13c62.

📒 Files selected for processing (10)
  • backend/models/__init__.py
  • backend/routes/quiz.py
  • backend/services/events_service.py
  • backend/tests/conftest.py
  • backend/tests/integration/test_quiz_subcutaneous_db.py
  • backend/tests/test_event_capture_seams.py
  • backend/tests/test_quiz_answers_c.py
  • backend/tests/test_quiz_provenance_e5_e6.py
  • backend/tests/test_quiz_routes.py
  • frontend/src/lib/quiz/api.ts

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

❤️ Share

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

@cloudflare-workers-and-pages

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

Branch Preview URL
Aug 26 2026, 06:06 AM

@supabase

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

AndresL230and others added 2 commits August 26, 2026 02:03
…w r2)
The one-release grace window for `include_answer_key` was gated on a
`logger.info`: nothing rolls log lines up, and the breadcrumb only ever
fired on an explicit true, so the population the window actually exists
for — flag-unaware callers that OMIT the field and silently changed
response shape at the flip — had no telemetry at all (F1).
Distinguish the three populations by `model_fields_set` (was the field on
the wire?) rather than by value, and emit an events_service event per
population so the count lands in the #375 admin-analytics by_event_type
rollup with no schema or endpoint work (F4, #117 convention):
* explicit true -> quiz.answer_key_served (the count that must reach
zero before the parameter is deleted; keeps the log line too)
* omitted -> quiz.answer_key_flag_omitted (deletion is a no-op
for them, but they are the shape-change population)
* explicit false -> nothing; every shipped #537 client sends this on
every generate and would swamp the rollup
Two event types rather than one with a `flag` payload field because
by_event_type does not break payloads out: one type would show a single
number mixing the population that blocks deletion with the one that
doesn't.
Also canonicalizes the flag's lifecycle prose on the field declaration
(F14) — it was restated at six sites.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…review r2)
Test strength:
- Both "keyless projection of the real stored key" tests asserted the
served shape but never that it WAS the projection: add count equality
vs the decrypted stored questions, per-index id/stem/option label+text
correspondence, and delivered_count computed off the projected list
(F2, F3).
- The ~30-line leak check (allowlist + key-set loops + recursive walk)
was duplicated across the hermetic and subcutaneous lanes and had
already drifted; it moves to one `assert_keyless_projection` fixture in
tests/conftest.py, which both lanes use (F11). Sharing a test-side
helper is not the circularity the anchor guards against — the
allowlists are still literals, never imported from routes.quiz.
- Option key drift now reports the symmetric difference, so a MISSING key
reports itself instead of only extras (F9).
- Pin the exact top-level response key set (F12).
- Consolidate the default-{} and explicit-false cases via parametrize,
keeping the caplog-silence assert (F13).
- Cover the three telemetry populations, including the no-event case.
Comments:
- test_quiz_routes: the keyed shape is not "what submit_quiz expects" —
submit grades from stored questions_json and reads only
question_id/selected_label (F5).
- test_quiz_answers_c: the #537 client has ALWAYS sent explicit false;
the omitting population is flag-unaware callers (F6).
- test_quiz_provenance: the keyed branch is no longer the default — this
PR is the flip (F7).
- frontend quiz/api.ts: the default is false now, so the client's
explicit false is belt-and-braces, not load-bearing (F8).
- Fix the vacuity-guard wording: the anchors sit below, not above (F10).
- Parse the subcutaneous response once (F15).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 merged commit 025474a into mainAug 26, 2026
8 checks passed
AndresL230 added a commit that referenced this pull request Aug 26, 2026
Brings in the include_answer_key default flip (#590), the dead
effective_explanations deletion (#587) and the CI port preflight (#588).
Clean auto-merge: main's routes/quiz.py hunks are in the generate
handler's response projection, this branch's are in the shared
gather/hoist above it.
AndresL230 added a commit that referenced this pull request Aug 28, 2026
Two conflicts, both in the files #590 and this branch both edited.
models/__init__.py: #590 flipped `include_answer_key`'s default to false and
replaced its comment with the canonical lifecycle account; this branch added
`source_attempt_id` / `missed_question_hashes` and the validator that ties
them together. Kept both — the flipped default and its prose verbatim, the
G5 fields ahead of it, the validator after.
routes/quiz.py: this branch relocated the agent-call except ladder out of
`generate_quiz` into `_generate_or_502` so the re-serve branch can catch a
failed top-up and still serve what it recovered; main (#592) added
`has_graph=True` to the same call. Kept the relocation and carried the new
argument (and its rationale) into the helper. #590's keyless projection,
#591's attempt helpers and #592's hoisted lookups merged cleanly and are
untouched — re-served questions still flow through the single
`_client_questions` call, which strips `question_hash`/`provenance` on both
the keyless and the opt-in keyed branch.
Two G5 tests were written against the pre-flip default and are re-pointed:
the keyed-projection test now opts in explicitly, and the keyless one adopts
main's shared `assert_keyless_projection` fixture, which grounds the check in
the answer key actually stored for the attempt. That fixture then caught G5's
conditional top-level `source` block, so the shared key literal now names
optional fields instead of requiring every response to carry them. The F5
assertions narrow to `quiz_reserve.missed_questions`: #592 makes an unrelated
reporter fire on the same request under these mocks.
Co-Authored-By: Claude Opus 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.

1 participant

@AndresL230