<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Akanksha Trehun</title>
    <description>The latest articles on DEV Community by Akanksha Trehun (@magic-peach).</description>
    <link>https://dev.to/magic-peach</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3909309%2F540870bc-2796-4ade-a0dd-68e2fce4e1c2.png</url>
      <title>DEV Community: Akanksha Trehun</title>
      <link>https://dev.to/magic-peach</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/magic-peach"/>
    <language>en</language>
    <item>
      <title>Circuitverse Assignment Enhancement Suite | GSoC 2026 | Final Report</title>
      <dc:creator>Akanksha Trehun</dc:creator>
      <pubDate>Thu, 29 Oct 2026 16:17:42 +0000</pubDate>
      <link>https://dev.to/magic-peach/lti-13-autograding-subgroups-and-templates-for-circuitverse-gsoc-2026-final-report-4na1</link>
      <guid>https://dev.to/magic-peach/lti-13-autograding-subgroups-and-templates-for-circuitverse-gsoc-2026-final-report-4na1</guid>
      <description>&lt;p&gt;The time was 11.30pm and the day was 30th of April 2026. I was sitting at my desk with a compiler design assignment open in front of me, pretending to be deeply engrossed in college work. In reality, I hadn't made an ounce of progress since my focus was completely hijacked by my GSoC application tab open on screen. I kept telling myself I’d just check in the morning, but my fingers had a mind of their own on the refresh button. When the page finally updated in my favour, all pretense of studying vanished.&lt;/p&gt;

&lt;p&gt;Flash forward to today: I'm writing my final Google Summer of Code 2026 report. Looking back across these past months, the journey has been an intense, incredible blur from tearing apart and rebuilding LTI 1.3 specs from scratch, to navigating PR feedback loops, untangling complex Rails authorization policies, and building an end-to-end autograding engine for digital circuits.&lt;/p&gt;

&lt;p&gt;Before diving into the code, here is a bit of context on how I got here.&lt;/p&gt;

&lt;h2&gt;
  
  
  About Me
&lt;/h2&gt;

&lt;p&gt;I’m Akanksha Trehun (magic-peach), a Computer Science &amp;amp; Engineering student at St. Joseph Engineering College, Mangalore in India.&lt;/p&gt;

&lt;p&gt;My relationship with CircuitVerse actually started back in my 3rd semester during a Digital Principles of Design lab. Our professor projected CircuitVerse onto the screen to demonstrate how logic gates, adders, and flip-flops worked in practice rather than just static textbook diagrams. That 20-minute demo stuck with me.&lt;/p&gt;

&lt;p&gt;Fast forward to my 6th semester: when the GSoC 2026 accepted organizations list came out and I saw CircuitVerse, it clicked immediately. I knew this was the right time to apply. Seventh and eighth semesters quickly get swallowed by final-year capstone projects and placement prep, so waiting for a "more convenient" moment meant it would never happen.&lt;/p&gt;

&lt;p&gt;I’ll be completely transparent—while I was familiar with git workflows and open-source contributions, the CircuitVerse backend runs on Ruby on Rails. Coming into February, I had zero prior experience with Ruby or Rails. It wasn’t part of my college coursework, and my initial glance at the repository felt overwhelming.&lt;/p&gt;

&lt;p&gt;So, I took it one step at a time:&lt;/p&gt;

&lt;p&gt;Watched beginner Rails tutorials and read through the CircuitVerse developer docs.&lt;/p&gt;

&lt;p&gt;Started small—fixing broken documentation, updating outdated specs, and tackling minor issue scopes.&lt;/p&gt;

&lt;p&gt;Balancing proposal writing at 5:45 AM before morning lectures wasn't easy, but seeing everything come together made every early alarm worth it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Project
&lt;/h2&gt;

&lt;p&gt;This summer, I worked on the Assignment Suite &amp;amp; Classroom Workflow Enhancement project under the mentorship of &lt;strong&gt;Anas Khan&lt;/strong&gt;, &lt;strong&gt;Aman Asrani&lt;/strong&gt;, and &lt;strong&gt;Aboobacker M K&lt;/strong&gt; as org admin.&lt;/p&gt;

&lt;p&gt;The main goal was to solve key operational bottlenecks for educators using CircuitVerse at scale:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LTI 1.3 &amp;amp; Canvas Integration:&lt;/strong&gt; Upgrading authentication from legacy OAuth 1.0a / LTI 1.1 to LTI 1.3 (OAuth 2.0 / OIDC) so institutions can seamlessly connect Canvas/Moodle with secure grade passback and roster sync.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Subgroup &amp;amp; Team Management:&lt;/strong&gt; Adding nested classroom structures to let instructors divide large course groups into smaller project teams with dedicated submission tracking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Autograding &amp;amp; Test Suite Editor:&lt;/strong&gt; Building a background execution workflow where instructors can define input/output test cases to automatically grade student circuit submissions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Starter Circuit Templates:&lt;/strong&gt;  Creating a template library so teachers can distribute pre-wired base circuits attached directly to assignments.&lt;/p&gt;

&lt;h2&gt;
  
  
  How It Unfolded
&lt;/h2&gt;

&lt;p&gt;My early contributions started with foundational bug fixes—like scoping AssignmentsController queries to policy scopes and refining invitation rules—which helped me understand CircuitVerse's internal data model inside out.&lt;/p&gt;

&lt;p&gt;By March, I drafted a comprehensive proposal complete with an end-to-end proof-of-concept branch demonstrating how LTI 1.3, subgroups, and autograding tied together. After clearing two technical interview rounds including one scheduled right before my compiler design lab exam. I was thrilled to join the 2026 cohort&lt;/p&gt;

&lt;p&gt;This post is about what that means for you, whether you teach, study or run a CircuitVerse install, and what you should expect to see and when.&lt;/p&gt;




&lt;h2&gt;
  
  
  Who this is for
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;If you are:&lt;/th&gt;
&lt;th&gt;What changes for you&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;An instructor&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Assignments launch from your LMS, grades go back to your gradebook, class lists stay in sync, you can split a class into teams, hand every student the same starting circuit and have a circuit graded by tests you write&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;A student&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;You open the assignment from your course without a second login, you start from the circuit your instructor prepared, you see which tests passed, and you can work on one shared circuit with your team&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;An LMS admin&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;You register CircuitVerse as an LTI 1.3 tool using a standard configuration, instead of the older shared-secret setup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;A CircuitVerse admin or maintainer&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Every new feature is behind a flag or a setting, so you can merge code without switching anything on&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  What you can use today, and what is coming
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Who it helps&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Grade saved in CircuitVerse before it is sent to the LMS (LTI 1.1)&lt;/td&gt;
&lt;td&gt;Instructors using LMS grading today&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Merged&lt;/strong&gt; (&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7647" rel="noopener noreferrer"&gt;#7647&lt;/a&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Foundation for LTI 1.3: platform registration, tool configuration, token checking, resource links, service tokens&lt;/td&gt;
&lt;td&gt;LMS admins&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Merged&lt;/strong&gt;, switched off by default&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LTI 1.3 sign-in from the LMS&lt;/td&gt;
&lt;td&gt;Everyone&lt;/td&gt;
&lt;td&gt;In review (&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7746" rel="noopener noreferrer"&gt;#7746&lt;/a&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deep linking, roster sync, grade passback services&lt;/td&gt;
&lt;td&gt;Instructors&lt;/td&gt;
&lt;td&gt;In review&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Autograding with test suites&lt;/td&gt;
&lt;td&gt;Instructors, students&lt;/td&gt;
&lt;td&gt;In review&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Subgroups (teams)&lt;/td&gt;
&lt;td&gt;Instructors, students&lt;/td&gt;
&lt;td&gt;In review&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Circuit templates&lt;/td&gt;
&lt;td&gt;Instructors, students&lt;/td&gt;
&lt;td&gt;In review&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  If you are an instructor
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Open an assignment from your LMS without a second login
&lt;/h3&gt;

&lt;p&gt;LTI is the standard that lets CircuitVerse sit inside Canvas or Moodle. The old version, LTI 1.1, proves a request is genuine. Canvas has deprecated that for new installs. LTI 1.3 uses signed tokens instead, so your LMS admin can register CircuitVerse the normal modern way.&lt;/p&gt;

&lt;p&gt;What this means for you: your students click the assignment in the course and land in CircuitVerse already signed in. What it means for safety: a launch is only trusted if three checks pass (a state value, a one-time nonce, and a match to the platform that started the login), and the signature on the token is verified.&lt;/p&gt;

&lt;p&gt;We identify people by the LMS's stable &lt;code&gt;sub&lt;/code&gt; identifier, scoped to the platform, and never by email. An email in a launch is whatever the LMS has on file, and users can often edit it themselves, so matching on it would let someone sign in as somebody else.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_or_create_by!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;provider: &lt;/span&gt;&lt;span class="s2"&gt;"lti"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;uid: &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;deployment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'sub'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The token validator is built to refuse the known tricks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;Lti&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;JwtValidator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;validate!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;deployment&lt;/span&gt;&lt;span class="p"&gt;:,&lt;/span&gt; &lt;span class="n"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;:)&lt;/span&gt;
&lt;span class="n"&gt;encoded_token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify_signature!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;algorithm: &lt;/span&gt;&lt;span class="s2"&gt;"RS256"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;key: &lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Grades reach the gradebook, and never disagree with CircuitVerse
&lt;/h3&gt;

&lt;p&gt;This one is &lt;strong&gt;live today&lt;/strong&gt; for LTI 1.1. Before, CircuitVerse pushed the score to LMS first and saved it second. If the local save failed, the LMS showed a grade that CircuitVerse had never recorded. Now the grade is saved first (&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7647" rel="noopener noreferrer"&gt;#7647&lt;/a&gt;). The review also found that a forged request could wipe a teacher's grading session before it was rejected, so stale context is now cleared only after a launch is verified.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;&lt;code&gt;app/controllers/grades_controller.rb&lt;/code&gt;:&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="vi"&gt;@grade&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;
  &lt;span class="n"&gt;render&lt;/span&gt; &lt;span class="ss"&gt;json: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;error: &lt;/span&gt;&lt;span class="s2"&gt;"Grade is invalid"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="ss"&gt;status: :bad_request&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="no"&gt;Flipper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enabled?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:lms_integration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:is_lti&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;submit_grade_to_lms&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;assignment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;grade&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For 1.3, the score push (&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/8029" rel="noopener noreferrer"&gt;#8029&lt;/a&gt;) sends autograded scores through the LMS's line item. It logs a failure instead of hiding it, and it uses a separate identity per LMS platform, so a student who uses two LMSs never has a score sent to the wrong one.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;&lt;code&gt;Autograder::AgsPush&lt;/code&gt;:&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="nb"&gt;sub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;link&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;author&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lti_sub_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lti_deployment&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="no"&gt;Lti&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Score&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lineitem_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;user_id: &lt;/span&gt;&lt;span class="nb"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;score_given: &lt;/span&gt;&lt;span class="n"&gt;given&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;score_maximum: &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kp"&gt;true&lt;/span&gt;
&lt;span class="k"&gt;rescue&lt;/span&gt; &lt;span class="no"&gt;Lti&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;AccessToken&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;Lti&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Score&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Error&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;
  &lt;span class="no"&gt;Rails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Autograder::AgsPush failed for project &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;message&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kp"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Pick CircuitVerse content from your course page
&lt;/h3&gt;

&lt;p&gt;Deep linking (&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7772" rel="noopener noreferrer"&gt;#7772&lt;/a&gt;, &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7777" rel="noopener noreferrer"&gt;#7777&lt;/a&gt;) lets you choose one of your circuits or assignments from inside the LMS, and the choice is posted back as a signed message.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep the class list in sync
&lt;/h3&gt;

&lt;p&gt;Today a roster enters CircuitVerse once, at launch. A student who drops the course stays in the group. The new &lt;strong&gt;Sync Roster&lt;/strong&gt; button (&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/8043" rel="noopener noreferrer"&gt;#8043&lt;/a&gt;) fetches the current roster, adds who joined, removes who left and records when it last worked. Only people who came from the LMS are removed. A student you added directly stays. I chose a button over an automatic sync so a flaky LMS shows up as a visible last synced time, not a silent failure.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;&lt;code&gt;GroupsController#sync_roster&lt;/code&gt;:&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Lti&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;AccessToken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lti_deployment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="no"&gt;Lti&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Membership&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;SCOPE&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="ss"&gt;signing_key: &lt;/span&gt;&lt;span class="no"&gt;Lti&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;KeyManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;private_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;members&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Lti&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Membership&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;context_memberships_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="no"&gt;ActiveRecord&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="no"&gt;Lti&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;RosterImport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="vi"&gt;@group&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;members&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lti_deployment&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="no"&gt;Lti&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;RosterDrop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="vi"&gt;@group&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;members&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lti_deployment&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="vi"&gt;@group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;last_synced_at: &lt;/span&gt;&lt;span class="no"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Write tests and let CircuitVerse grade submissions
&lt;/h3&gt;

&lt;p&gt;You write a test suite in an editor on the assignment page (&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7907" rel="noopener noreferrer"&gt;#7907&lt;/a&gt;). Each test case is a named set of input values and expected outputs, and you can mark it &lt;strong&gt;hidden&lt;/strong&gt;. When a student submits, a background job runs the circuit and stores a result (&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7942" rel="noopener noreferrer"&gt;#7942&lt;/a&gt;). Three settings give you control (&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7926" rel="noopener noreferrer"&gt;#7926&lt;/a&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Partial credit:&lt;/strong&gt; give a fraction of the score, or all or nothing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Maximum attempts:&lt;/strong&gt; how many graded runs a student gets&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reveal test cases before submission:&lt;/strong&gt; whether students see the cases up front&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;&lt;code&gt;AutogradeJob&lt;/code&gt;:&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;perform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;project_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;project&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;id: &lt;/span&gt;&lt;span class="n"&gt;project_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;assignment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assignment&lt;/span&gt;
  &lt;span class="n"&gt;testbench&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;assignment&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;testbench&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;testbench&lt;/span&gt;

  &lt;span class="n"&gt;max&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;assignment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max_attempts&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;max&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="no"&gt;GradingResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;project: &lt;/span&gt;&lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;max&lt;/span&gt;

  &lt;span class="k"&gt;begin&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Autograder&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;project: &lt;/span&gt;&lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;testbench: &lt;/span&gt;&lt;span class="n"&gt;testbench&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;breakdown&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;groups&lt;/span&gt;
    &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;score&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;rescue&lt;/span&gt; &lt;span class="no"&gt;Autograder&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Runner&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;RunnerError&lt;/span&gt;
    &lt;span class="n"&gt;breakdown&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kp"&gt;nil&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="no"&gt;GradingResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;project: &lt;/span&gt;&lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;suite_snapshot: &lt;/span&gt;&lt;span class="n"&gt;testbench&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="ss"&gt;breakdown: &lt;/span&gt;&lt;span class="n"&gt;breakdown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;score: &lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The score is turned into the scale your assignment uses (percent or letter), and an LMS-linked assignment is always a percentage, because that is what the gradebook expects (&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7944" rel="noopener noreferrer"&gt;#7944&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;&lt;em&gt;&lt;code&gt;GradeScaleMapper&lt;/code&gt;:&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;LETTER_THRESHOLDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"A"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"B"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;70&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"C"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"D"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"E"&lt;/span&gt;&lt;span class="p"&gt;]].&lt;/span&gt;&lt;span class="nf"&gt;freeze&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;assignment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fraction&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;mappable?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;assignment&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="n"&gt;percent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fraction&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;scale_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;assignment&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="s2"&gt;"letter"&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="no"&gt;LETTER_THRESHOLDS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;min&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;percent&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;min&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;last&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s2"&gt;"F"&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;percent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_s&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scale_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;assignment&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;assignment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lti_integrated?&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;"percent"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;assignment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;grading_scale&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every result stores a copy of the suite it ran against. If you edit your tests later, grades already given still show the tests they were earned against, so you never see a grade next to a test it was not run on.&lt;/p&gt;

&lt;h3&gt;
  
  
  Split a class into teams
&lt;/h3&gt;

&lt;p&gt;Instructors asked for teams, so a group page gets a &lt;strong&gt;Subgroups&lt;/strong&gt; section (&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/8053" rel="noopener noreferrer"&gt;#8053&lt;/a&gt;). You create a team, then choose its members from the parent group's list. Each team gets a progress line such as 2 assignments, 5 submitted, 3 graded (&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/8058" rel="noopener noreferrer"&gt;#8058&lt;/a&gt;), and you can designate one &lt;strong&gt;team project&lt;/strong&gt; that everyone in the team collaborates on (&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/8064" rel="noopener noreferrer"&gt;#8064&lt;/a&gt;). A REST API covers the same actions (&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/8056" rel="noopener noreferrer"&gt;#8056&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;A subgroup is just a group with a parent, so everything that already works on groups (assignments, grades, mentors) works on a team. Creating a team does not send the "new group" email, so sorting a class into ten teams does not send ten emails. Teams do not show up in "my groups" next to your real classes.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;&lt;code&gt;Group&lt;/code&gt;:&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;scope&lt;/span&gt; &lt;span class="ss"&gt;:top_level&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;parent_group_id: &lt;/span&gt;&lt;span class="kp"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;validates&lt;/span&gt; &lt;span class="ss"&gt;:name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;uniqueness: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;scope: :parent_group_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;case_sensitive: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="ss"&gt;if: :subgroup?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Sharing the team project reuses &lt;code&gt;Collaboration&lt;/code&gt;:&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;belongs_to&lt;/span&gt; &lt;span class="ss"&gt;:team_project&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;class_name: &lt;/span&gt;&lt;span class="s2"&gt;"Project"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;optional: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;
&lt;span class="n"&gt;validate&lt;/span&gt; &lt;span class="ss"&gt;:team_project_author_must_be_member&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;if: :team_project_id?&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;share_team_project&lt;/span&gt;
  &lt;span class="n"&gt;group_members&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;not&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;user_id: &lt;/span&gt;&lt;span class="n"&gt;team_project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;author_id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;pluck&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:user_id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
    &lt;span class="no"&gt;Collaboration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_or_create_by!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;project_id: &lt;/span&gt;&lt;span class="n"&gt;team_project_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;user_id: &lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7wtvaypxefdy8tezk43r.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7wtvaypxefdy8tezk43r.jpg" alt="The subgroups section before any team exists" width="799" height="455"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiwcnrxaggbmaig8y8tun.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiwcnrxaggbmaig8y8tun.jpg" alt="Creating a subgroup from the group page" width="799" height="455"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9ez4wngt4a6zou4yobab.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9ez4wngt4a6zou4yobab.jpg" alt="The subgroups section on a group page" width="799" height="455"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqq9q8yy9auzvqhbu0d00.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqq9q8yy9auzvqhbu0d00.jpg" alt="Managing a subgroup's members" width="799" height="455"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffuwqpwv35s3smnkqglx1.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffuwqpwv35s3smnkqglx1.jpg" alt="Confirming the deletion of a subgroup" width="799" height="455"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff1fqjmkrjf0ftntb1vbi.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff1fqjmkrjf0ftntb1vbi.jpg" alt="A subgroup page with a breadcrumb back to the parent group" width="799" height="455"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Give every student the same starting circuit
&lt;/h3&gt;

&lt;p&gt;Templates are circuits you can save, share and use as the base of an assignment (in review, &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/8065" rel="noopener noreferrer"&gt;#8065&lt;/a&gt; to &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/8107" rel="noopener noreferrer"&gt;#8107&lt;/a&gt;). The flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Save as template&lt;/strong&gt; from one of your projects. An assignment project cannot be a template, because it is a student's working copy.&lt;/li&gt;
&lt;li&gt;Choose who can find it: &lt;strong&gt;private&lt;/strong&gt; (only you), &lt;strong&gt;group&lt;/strong&gt; (people who share a group with you, plus those groups' mentors) or &lt;strong&gt;public&lt;/strong&gt; (everyone).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Browse the gallery&lt;/strong&gt;, filtered by tag.&lt;/li&gt;
&lt;li&gt;When you create an assignment, &lt;strong&gt;pick a base template&lt;/strong&gt;. Each student's project starts from a copy when they open the assignment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fork&lt;/strong&gt; a template to customise it, and see &lt;strong&gt;how many assignments use it&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv1sw1xdv86n6s8ai3vxs.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv1sw1xdv86n6s8ai3vxs.jpg" alt="A circuit saved as a template" width="799" height="455"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc2g9huohqpzddmhkybqo.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc2g9huohqpzddmhkybqo.jpg" alt="Saving an assignment project as a template is refused" width="799" height="455"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2ac1ify38uvidvnkpw5y.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2ac1ify38uvidvnkpw5y.jpg" alt="The template gallery" width="799" height="455"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F69j5ofevlefsm95af5ea.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F69j5ofevlefsm95af5ea.jpg" alt="The gallery filtered by a tag" width="799" height="455"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhavz0yz77sv91k6y9in6.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhavz0yz77sv91k6y9in6.jpg" alt="The template options on the assignment form" width="799" height="455"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F20d6n2gw5s7j5414hm23.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F20d6n2gw5s7j5414hm23.jpg" alt="Choosing a base template when creating an assignment" width="799" height="455"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fllwf9bws5d7o4v09uzfj.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fllwf9bws5d7o4v09uzfj.jpg" alt="The fork button on a template page" width="799" height="455"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb9xmsvhgygkow4xaxy0j.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb9xmsvhgygkow4xaxy0j.jpg" alt="The forked copy as a normal project" width="799" height="455"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8d5nzh88f6o7dafjsycp.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8d5nzh88f6o7dafjsycp.jpg" alt="Usage counts in the gallery" width="799" height="455"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe2jzqcbhyzs5m2cclhqy.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe2jzqcbhyzs5m2cclhqy.jpg" alt="Usage count on the template page" width="799" height="455"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A template can also carry its own test suite, and an assignment that uses it gets a copy of the tests, unless you have already written your own (&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/8092" rel="noopener noreferrer"&gt;#8092&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Visibility is enforced in one place, so what the gallery lists, what the picker offers and what the server accepts are always the same list. A template you cannot see returns a 404 if you guess its URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;scope&lt;/span&gt; &lt;span class="ss"&gt;:templates_for&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;lambda&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;is_template: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;template_public&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;nil?&lt;/span&gt;

  &lt;span class="n"&gt;groups&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;id: &lt;/span&gt;&lt;span class="no"&gt;GroupMember&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;user_id: &lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:group_id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;or&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;primary_mentor_id: &lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;peers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;GroupMember&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;group_id: &lt;/span&gt;&lt;span class="n"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;mentors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;id: &lt;/span&gt;&lt;span class="n"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:primary_mentor_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;shared&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;template_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;author_id: &lt;/span&gt;&lt;span class="n"&gt;peers&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;or&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;template_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;author_id: &lt;/span&gt;&lt;span class="n"&gt;mentors&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="n"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;is_template: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;and&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;template_public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;or&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;or&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;shared&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  If you are a student
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You open the assignment from your course and you are signed in.&lt;/strong&gt; No second account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You start from your instructor's circuit.&lt;/strong&gt; The copy is made when you open the assignment, so you get the template as it stands then. If its author made it private in the meantime, the copy is checked again, so you are never handed a circuit you should not have.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You see which tests passed.&lt;/strong&gt; A results page shows your score and one row per test case. Hidden tests show only pass or fail: the inputs and expected outputs are not printed, so you cannot write a circuit that satisfies a list instead of solving the problem. If your instructor allows revealing, you see everything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only people who should see your result can.&lt;/strong&gt; The page uses the same access rule as the project itself, which for an assignment means you, your instructor and your collaborators.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Teams share one circuit.&lt;/strong&gt; If your group is split into teams, everyone in your team collaborates on the team project.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is how the results page decides what to print. It reads only the saved copy of the suite, so rows always line up with their verdicts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;visible_test_groups&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reveal_hidden&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;snapshot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;suite_snapshot&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"groups"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
  &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;breakdown&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each_with_index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flat_map&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
    &lt;span class="n"&gt;hidden&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"hidden"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;reveal_hidden&lt;/span&gt;
    &lt;span class="no"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"cases"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;each_with_index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"label"&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;case_label&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s2"&gt;"passed"&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"passed"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s2"&gt;"hidden"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s2"&gt;"inputs"&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;hidden&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kp"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pin_values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"inputs"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s2"&gt;"outputs"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;hidden&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kp"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pin_values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"outputs"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And this is the check that runs when a template is copied into your project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;template_circuit_data&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;circuit_template&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;circuit_template&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;project_access_type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"Private"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;template_author_mentors_group?&lt;/span&gt;

  &lt;span class="n"&gt;circuit_template&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;project_datum&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  If you run an LMS or a CircuitVerse install
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Nothing turns on by itself.&lt;/strong&gt; LTI 1.3 endpoints return 404 until an operator enables the &lt;code&gt;lti_advantage&lt;/code&gt; flag. The launch creates user accounts, so I did not want that live the moment code merged. The autograde settings are also gated by a flag.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LTI 1.1 keeps working.&lt;/strong&gt; The 1.3 code is built on &lt;code&gt;ruby-jwt&lt;/code&gt; next to it, and the &lt;code&gt;ims-lti&lt;/code&gt; dependency stays for the old path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You register CircuitVerse the standard way.&lt;/strong&gt; There is a platform registration model (&lt;code&gt;LtiDeployment&lt;/code&gt;), a JWKS endpoint and a tool configuration document, all merged. The configuration includes Canvas placements, so instructors can find the tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Autograding needs a simulator service.&lt;/strong&gt; The app calls a separate service to run a circuit, because the simulator lives in the browser and Rails cannot run it. You set &lt;code&gt;SIMULATOR_RUNNER_URL&lt;/code&gt; and &lt;code&gt;SIMULATOR_RUNNER_TOKEN&lt;/code&gt;. There are no defaults on purpose: a missing URL fails loudly instead of sending student circuits to localhost. &lt;strong&gt;That service is not in this repository yet&lt;/strong&gt;, so autograding will not work until it exists.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;SIMULATOR_RUNNER_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;
&lt;span class="nv"&gt;SIMULATOR_RUNNER_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;HTTP&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="no"&gt;TIMEOUTS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Bearer &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SIMULATOR_RUNNER_TOKEN'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;json: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;circuit: &lt;/span&gt;&lt;span class="n"&gt;circuit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;testbench: &lt;/span&gt;&lt;span class="n"&gt;testbench&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  What to expect
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Timing.&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;Expect features to appear gradually, not all at once.&lt;/li&gt;
&lt;li&gt;Nothing from LTI 1.3 will be visible to instructors until the sign-in pull request (&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7746" rel="noopener noreferrer"&gt;#7746&lt;/a&gt;) merges and an operator enables the flag.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Things that behave as designed that are new.&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;A template's visibility is separate from the project's access. A project can be public without being offered as a template.&lt;/li&gt;
&lt;li&gt;Collaborators cannot save someone else's circuit as a template. Only the author, an admin or a mentor of the author's group can.&lt;/li&gt;
&lt;li&gt;If you delete a template that an assignment uses, the assignment keeps working and simply loses the link.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  How it was built, in short
&lt;/h2&gt;

&lt;p&gt;Four habits shaped every feature, and they are the reason I trust the parts that are finished.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Small pull requests.&lt;/strong&gt; I built everything in the form of small pull requests in order to make them simple to review and easier to understand at a later date by future contributors&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check access where the data moves.&lt;/strong&gt; The biggest review findings were all the same question: someone is about to be given something, so was anyone checking whether they should have it at that moment? That is why a template is checked again at copy time, and why hidden test cases are redacted on the results page and not in the database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reuse before inventing.&lt;/strong&gt; A test case is a group with &lt;code&gt;n: 1&lt;/code&gt;, the simulator's own format. A team project reuses &lt;code&gt;Collaboration&lt;/code&gt;. A subgroup is a &lt;code&gt;Group&lt;/code&gt; with a parent.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Pull requests
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Merged:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;PR&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7647" rel="noopener noreferrer"&gt;#7647&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Saves a grade locally before pushing it to the LMS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7654" rel="noopener noreferrer"&gt;#7654&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;LtiDeployment&lt;/code&gt; model for platform registrations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7659" rel="noopener noreferrer"&gt;#7659&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;JWKS and tool configuration endpoints&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7715" rel="noopener noreferrer"&gt;#7715&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Lti::JwtValidator&lt;/code&gt; for &lt;code&gt;id_token&lt;/code&gt; verification&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7763" rel="noopener noreferrer"&gt;#7763&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;LtiResourceLink&lt;/code&gt; for launch service endpoints&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7764" rel="noopener noreferrer"&gt;#7764&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Lti::AccessToken&lt;/code&gt; for platform service calls&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;In review:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;PRs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;LTI 1.3 sign-in and services&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7746" rel="noopener noreferrer"&gt;#7746&lt;/a&gt;, &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7765" rel="noopener noreferrer"&gt;#7765&lt;/a&gt;, &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7767" rel="noopener noreferrer"&gt;#7767&lt;/a&gt;, &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7772" rel="noopener noreferrer"&gt;#7772&lt;/a&gt;, &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7777" rel="noopener noreferrer"&gt;#7777&lt;/a&gt;, &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7779" rel="noopener noreferrer"&gt;#7779&lt;/a&gt;, &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7781" rel="noopener noreferrer"&gt;#7781&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Autograding&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7836" rel="noopener noreferrer"&gt;#7836&lt;/a&gt;, &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7906" rel="noopener noreferrer"&gt;#7906&lt;/a&gt;, &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7907" rel="noopener noreferrer"&gt;#7907&lt;/a&gt;, &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7926" rel="noopener noreferrer"&gt;#7926&lt;/a&gt;, &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7940" rel="noopener noreferrer"&gt;#7940&lt;/a&gt;, &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7942" rel="noopener noreferrer"&gt;#7942&lt;/a&gt;, &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7944" rel="noopener noreferrer"&gt;#7944&lt;/a&gt;, &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/8003" rel="noopener noreferrer"&gt;#8003&lt;/a&gt;, &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/8029" rel="noopener noreferrer"&gt;#8029&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Subgroups and roster&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/8038" rel="noopener noreferrer"&gt;#8038&lt;/a&gt;, &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/8043" rel="noopener noreferrer"&gt;#8043&lt;/a&gt;, &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/8046" rel="noopener noreferrer"&gt;#8046&lt;/a&gt;, &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/8048" rel="noopener noreferrer"&gt;#8048&lt;/a&gt;, &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/8053" rel="noopener noreferrer"&gt;#8053&lt;/a&gt;, &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/8055" rel="noopener noreferrer"&gt;#8055&lt;/a&gt;, &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/8056" rel="noopener noreferrer"&gt;#8056&lt;/a&gt;, &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/8058" rel="noopener noreferrer"&gt;#8058&lt;/a&gt;, &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/8064" rel="noopener noreferrer"&gt;#8064&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Templates&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/8065" rel="noopener noreferrer"&gt;#8065&lt;/a&gt;, &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/8068" rel="noopener noreferrer"&gt;#8068&lt;/a&gt;, &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/8069" rel="noopener noreferrer"&gt;#8069&lt;/a&gt;, &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/8070" rel="noopener noreferrer"&gt;#8070&lt;/a&gt;, &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/8084" rel="noopener noreferrer"&gt;#8084&lt;/a&gt;, &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/8092" rel="noopener noreferrer"&gt;#8092&lt;/a&gt;, &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/8100" rel="noopener noreferrer"&gt;#8100&lt;/a&gt;, &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/8102" rel="noopener noreferrer"&gt;#8102&lt;/a&gt;, &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/8107" rel="noopener noreferrer"&gt;#8107&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  What comes next
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Get the stack reviewed and merged in order, starting with LTI 1.3 sign-in.&lt;/li&gt;
&lt;li&gt;Fix the known gaps above, beginning with removing a student from their teams when they leave the parent group.&lt;/li&gt;
&lt;li&gt;Land the simulator service, so autograding can run for real.&lt;/li&gt;
&lt;li&gt;A screen for authoring a test suite on a template.&lt;/li&gt;
&lt;li&gt;A short guide for instructors and LMS admins moving from LTI 1.1 to 1.3.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you teach with CircuitVerse and want to try any of this, or hit one of the gaps above, open an issue or comment on the pull request. Real classroom feedback is the most useful thing the stack can get now.&lt;/p&gt;




&lt;h2&gt;
  
  
  Acknowledgements
&lt;/h2&gt;

&lt;p&gt;I would like to express my gratitude to my Org Admin &lt;strong&gt;Aboobacker M K and my mentors, Anas Khan&lt;/strong&gt; and &lt;strong&gt;Aman Asrani&lt;/strong&gt;, for the reviews that shaped this project.&lt;/p&gt;

&lt;p&gt;Thank you to the CircuitVerse community and contributors for this wonderful platform.&lt;/p&gt;

&lt;p&gt;Thank you to Google Summer of Code for the opportunity to code, learn and become a better developer.&lt;/p&gt;

</description>
      <category>gsoc</category>
      <category>lti</category>
      <category>opensource</category>
      <category>security</category>
    </item>
    <item>
      <title>Week 21: Access Has to Be Checked Where the Data Moves</title>
      <dc:creator>Akanksha Trehun</dc:creator>
      <pubDate>Thu, 29 Oct 2026 15:49:21 +0000</pubDate>
      <link>https://dev.to/magic-peach/week-21-access-has-to-be-checked-where-the-data-moves-1kjp</link>
      <guid>https://dev.to/magic-peach/week-21-access-has-to-be-checked-where-the-data-moves-1kjp</guid>
      <description>&lt;h1&gt;
  
  
  Week 21: Access Has to Be Checked Where the Data Moves
&lt;/h1&gt;

&lt;p&gt;Eleven pull requests opened this week: five for subgroups on the 19th and 20th, four for circuit templates on the 20th, and two more&lt;br&gt;
for templates on the 23rd and the 25th. The subject changed halfway through. The review findings did not. Almost every comment, on both features,&lt;br&gt;
was the same question in a different place: someone is about to be given something, and was anyone checking whether they should have&lt;br&gt;
it at that moment?&lt;/p&gt;
&lt;h2&gt;
  
  
  The subgroup screen, with no new JavaScript
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;#8053&lt;/code&gt; is the part an instructor actually touches. A top level group's page gets a subgroups section: a list, an Add Subgroup button,&lt;br&gt;
and for each subgroup a Manage Members dialog with checkboxes of the parent's members and a Delete confirmation. It is 441 added&lt;br&gt;
lines, and it adds no JavaScript, no routes and no controllers, because the actions already existed from the previous pull requests.&lt;/p&gt;

&lt;p&gt;The two alternatives were a Stimulus controller with one shared dialog and a separate page for managing members. The shared dialog is&lt;br&gt;
less markup and needs script to rewrite the form's target for each subgroup, which is more code to test. A separate page avoids dialogs&lt;br&gt;
and adds a route, an action and a view for a short checkbox list. Rendering one dialog per subgroup, with ids like&lt;br&gt;
&lt;code&gt;manage-subgroup-&amp;lt;id&amp;gt;&lt;/code&gt;, uses Bootstrap's &lt;code&gt;data-bs-toggle&lt;/code&gt; and &lt;code&gt;data-bs-target&lt;/code&gt; and the styles the app already has.&lt;/p&gt;

&lt;p&gt;The section is gated by &lt;code&gt;manage_subgroups?&lt;/code&gt; in the view as well as the server, so a student never sees a control the server would&lt;br&gt;
refuse. That is the habit I would defend: a button the server rejects is a UI bug, and the check in the view and the check in the&lt;br&gt;
controller come from one policy method so they cannot disagree.&lt;/p&gt;

&lt;p&gt;AccessLint found the one real defect in the markup: a &lt;code&gt;&amp;lt;ul&amp;gt;&lt;/code&gt; whose direct children were &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;s, which breaks the list semantics a&lt;br&gt;
screen reader relies on.&lt;/p&gt;
&lt;h2&gt;
  
  
  The user who left the class and kept the team
&lt;/h2&gt;

&lt;p&gt;The security finding of the week came on that pull request and again on the progress one:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This filter checks IDs submitted to &lt;code&gt;update_members&lt;/code&gt;, but it does not handle an existing subgroup member removed from the parent&lt;br&gt;
group. That user retains the subgroup membership and can still pass &lt;code&gt;GroupPolicy#show_access?&lt;/code&gt; for the subgroup.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A subgroup member is a &lt;code&gt;GroupMember&lt;/code&gt; row on the subgroup. Removing someone from the parent group deletes the parent's row and nothing&lt;br&gt;
else. The model has no callback that follows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GroupMember&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationRecord&lt;/span&gt;
  &lt;span class="n"&gt;belongs_to&lt;/span&gt; &lt;span class="ss"&gt;:group&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;counter_cache: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;
  &lt;span class="n"&gt;belongs_to&lt;/span&gt; &lt;span class="ss"&gt;:user&lt;/span&gt;
  &lt;span class="n"&gt;after_commit&lt;/span&gt; &lt;span class="ss"&gt;:send_welcome_email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;on: :create&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;unless: &lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subgroup?&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So a student dropped from the class, whether by a mentor or by the roster sync from last week, is still a member of a team inside it and&lt;br&gt;
can still open its page and its shared project. I checked that against the branch rather than trusting the comment, and it is true. It&lt;br&gt;
is not in this pull request and I am not going to pretend it is. The fix is its own change, one that removes the user's subgroup&lt;br&gt;
memberships when the parent membership goes, and it has to cover the API destroy path too. Two ways to remove a member, and only&lt;br&gt;
the one I thought of while writing the code was safe.&lt;/p&gt;
&lt;h2&gt;
  
  
  A missing parameter that deletes a roster
&lt;/h2&gt;

&lt;p&gt;Subgroups got a JSON API on the 19th (&lt;code&gt;#8056&lt;/code&gt;): list and create under a group, and show, delete and update members by the subgroup's&lt;br&gt;
own id. Reads go through the group's existing &lt;code&gt;show_access?&lt;/code&gt;. Writes authorize the parent with &lt;code&gt;manage_subgroups?&lt;/code&gt;, and the members&lt;br&gt;
endpoint calls the same &lt;code&gt;Group#sync_subgroup_members&lt;/code&gt; as the web controller, so the two cannot drift. Index and create are nested&lt;br&gt;
because they only make sense with a parent. Show, destroy and members use the subgroup id alone so a client that already holds one does&lt;br&gt;
not have to know the parent.&lt;/p&gt;

&lt;p&gt;The review found what happens when the request is sloppy:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;params[:user_ids]&lt;/code&gt; is passed unfiltered to &lt;code&gt;sync_subgroup_members&lt;/code&gt;. A request without the key removes every non-mentor member of the&lt;br&gt;
subgroup. A request with a hash or nested value raises on &lt;code&gt;to_i&lt;/code&gt;, because &lt;code&gt;Hash#to_i&lt;/code&gt; does not exist and the request returns 500. Use&lt;br&gt;
&lt;code&gt;params.expect(user_ids: [])&lt;/code&gt; so the API returns 400 for a missing or malformed key.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;"Set the members to this list" with a missing list is not a no-op. It is "set the members to nothing." An API call that forgets a field&lt;br&gt;
had the same effect as one that meant to empty the team. The web controller does the same thing in one line,&lt;br&gt;
&lt;code&gt;Array(params[:user_ids]).map(&amp;amp;:to_i)&lt;/code&gt;, so the bug is in both. Making the parameter required changes a silent destructive default into&lt;br&gt;
a 400, which is the right trade for an endpoint whose whole job is replacing a list.&lt;/p&gt;

&lt;p&gt;A smaller one on the web side, from the shared team project pull request: &lt;code&gt;manage_subgroups?&lt;/code&gt; lets a parent group's co-mentor act, but&lt;br&gt;
&lt;code&gt;show_access?&lt;/code&gt; does not let them view a subgroup they are not a member of. A co-mentor could update the members and then be redirected&lt;br&gt;
to a page they are not allowed to see. The redirect should stay on the parent page. Two policy methods that answer slightly different&lt;br&gt;
questions will always eventually be used in the same sentence.&lt;/p&gt;
&lt;h2&gt;
  
  
  Progress and the team project
&lt;/h2&gt;

&lt;p&gt;The progress rollup (&lt;code&gt;#8058&lt;/code&gt;) puts a line next to each subgroup, such as "2 assignments, 5 submitted, 3 graded", so an instructor can see&lt;br&gt;
how teams are doing without opening each one. It is a model method with three counts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;progress&lt;/span&gt;
  &lt;span class="n"&gt;submitted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;assignment_id: &lt;/span&gt;&lt;span class="n"&gt;assignments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="ss"&gt;project_submission: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;assignments: &lt;/span&gt;&lt;span class="n"&gt;assignments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;submissions: &lt;/span&gt;&lt;span class="n"&gt;submitted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;graded: &lt;/span&gt;&lt;span class="no"&gt;Grade&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;project_id: &lt;/span&gt;&lt;span class="n"&gt;submitted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:id&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I did not add cached counters. They can drift when a project is un-submitted or a grade is deleted, and they need callbacks and a&lt;br&gt;
migration for numbers read on exactly one page. A bug on that page was a fair one: "1 assignments". The fix on the 20th is a plural&lt;br&gt;
form in the locale file, not a conditional in the view.&lt;/p&gt;

&lt;p&gt;The team project (&lt;code&gt;#8064&lt;/code&gt;) lets a subgroup designate one project and have everyone in the subgroup collaborate on it. Rather than a new&lt;br&gt;
table or a group level collaborator type, it reuses &lt;code&gt;Collaboration&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;belongs_to&lt;/span&gt; &lt;span class="ss"&gt;:team_project&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;class_name: &lt;/span&gt;&lt;span class="s2"&gt;"Project"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;optional: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;
&lt;span class="n"&gt;validate&lt;/span&gt; &lt;span class="ss"&gt;:team_project_author_must_be_member&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;if: :team_project_id?&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;share_team_project&lt;/span&gt;
  &lt;span class="n"&gt;group_members&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;not&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;user_id: &lt;/span&gt;&lt;span class="n"&gt;team_project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;author_id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;pluck&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:user_id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
    &lt;span class="no"&gt;Collaboration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_or_create_by!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;project_id: &lt;/span&gt;&lt;span class="n"&gt;team_project_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;user_id: &lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reusing &lt;code&gt;Collaboration&lt;/code&gt; means every existing project access rule applies unchanged, and the alternative, teaching the project policy about&lt;br&gt;
groups, would have touched rules that matter far more than this feature. The validation says the project's author must be a member&lt;br&gt;
of the subgroup. Without it, a mentor could attach someone else's private circuit to a team and share it with everyone in it, so the&lt;br&gt;
rule is an access check disguised as data validation. &lt;code&gt;find_or_create_by!&lt;/code&gt; makes sharing idempotent, so running it twice, as happens&lt;br&gt;
when members are updated, does not duplicate collaborations. Deleting the project clears the link rather than failing or deleting the&lt;br&gt;
subgroup, because the foreign key is &lt;code&gt;on_delete: :nullify&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Templates: three places a circuit can be handed to someone
&lt;/h2&gt;

&lt;p&gt;On the 20th the template stack went up, four small pull requests in a chain: a flag and a visibility enum on &lt;code&gt;Project&lt;/code&gt;, a&lt;br&gt;
save-as-template action, a gallery, and a link from an assignment to a template. The fifth, on the 23rd, is the one where a student&lt;br&gt;
actually receives a copy. Every one of them is a different answer to "who may receive this circuit".&lt;/p&gt;

&lt;p&gt;The rules start small. An assignment project cannot be a template, because it is a student's working copy and publishing it would&lt;br&gt;
expose submitted work. Saving as a template is allowed for the author, an admin, and a mentor of a group the author belongs to, and&lt;br&gt;
refused for collaborators, because a collaborator can edit a circuit without owning it. &lt;code&gt;is_template&lt;/code&gt; and &lt;code&gt;template_visibility&lt;/code&gt; are&lt;br&gt;
separate from &lt;code&gt;project_access_type&lt;/code&gt;: access decides who can view a project and visibility decides who can find and copy it, and a&lt;br&gt;
project can be public without being offered.&lt;/p&gt;

&lt;p&gt;That separation is exactly what the first review attacked, on the gallery:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If a private project is saved with &lt;code&gt;template_visibility: "public"&lt;/code&gt;, the gallery selects it, but &lt;code&gt;check_view_access&lt;/code&gt; denies its detail&lt;br&gt;
page to anonymous visitors. The save action permits this combination. Apply one access rule across template eligibility, the&lt;br&gt;
gallery, and the detail page.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two independent switches create a combination nobody designed. The template could be listed, with a card, and refuse to open. The&lt;br&gt;
direction of the fix is a single rule that every path uses, rather than a patch for each page.&lt;/p&gt;
&lt;h2&gt;
  
  
  The copy that has to ask again
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;#8070&lt;/code&gt; lets an instructor pick a base template for an assignment. It stores a reference, not a copy, and a controller check limits&lt;br&gt;
the choice to templates the instructor could use, with the same &lt;code&gt;Project.templates_for&lt;/code&gt; scope that fills the dropdown, so what the&lt;br&gt;
form offers and what the server accepts are always the same list. CodeQL flagged the first version of the check as a SQL query built&lt;br&gt;
from user input, which was fair, and the check became &lt;code&gt;exists?(id: template_id)&lt;/code&gt;, which lets ActiveRecord bind the value.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;#8084&lt;/code&gt; makes the link do something: when a student opens an assignment, their project starts from the template's circuit. It copies&lt;br&gt;
only the circuit data rather than calling &lt;code&gt;Project#fork&lt;/code&gt;, because a fork also notifies the template's author and attaches a preview&lt;br&gt;
image, and a student opening an assignment is not a fork and a template with no preview would raise. It copies when the student&lt;br&gt;
opens it, not when the instructor saves, so each student gets the template as it stands when they start and the instructor can still&lt;br&gt;
fix it before anyone begins.&lt;/p&gt;

&lt;p&gt;That choice created the review's best finding:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;template_visibility: private&lt;/code&gt; alone does not make the underlying project private. The privacy issue is reachable when the author&lt;br&gt;
changes the linked template's &lt;code&gt;project_access_type&lt;/code&gt; to &lt;code&gt;"Private"&lt;/code&gt;. The assignment keeps its existing template link, and both&lt;br&gt;
&lt;code&gt;AssignmentsController#start&lt;/code&gt; and &lt;code&gt;LtiController#create_project_if_student_present&lt;/code&gt; copy the linked template's current project data&lt;br&gt;
without checking its current access.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Because the assignment stores a reference and the copy happens later, access has to be decided at the later time, and I had only&lt;br&gt;
decided it at the earlier one. The instructor chose a legitimate template on Monday, its author made the project private on Tuesday,&lt;br&gt;
and on Wednesday a student received its data. The copy now asks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;template_circuit_data&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;circuit_template&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;circuit_template&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;project_access_type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"Private"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;template_author_mentors_group?&lt;/span&gt;

  &lt;span class="n"&gt;circuit_template&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;project_datum&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A private template is still copied when its author is a mentor of the assignment's group, since in that case the student is receiving&lt;br&gt;
it from their own teacher, and refused otherwise. Both the web start action and the LTI launch call the same method, so there is one&lt;br&gt;
place that answers.&lt;/p&gt;
&lt;h2&gt;
  
  
  A template that carries its own tests
&lt;/h2&gt;

&lt;p&gt;The last pull request of the week, on the 25th, lets a template carry a test suite and hands it to the assignments that use it. It adds&lt;br&gt;
a &lt;code&gt;template_testbench&lt;/code&gt; column to projects and validates it by building a &lt;code&gt;Testbench&lt;/code&gt; around it, so a suite on a template has to pass&lt;br&gt;
the same shape, label and pin checks as any other suite instead of getting a second, weaker validator:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;validate&lt;/span&gt; &lt;span class="ss"&gt;:template_testbench_is_runnable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;if: :template_testbench?&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;template_testbench_is_runnable&lt;/span&gt;
  &lt;span class="n"&gt;suite&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Testbench&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;assignment: &lt;/span&gt;&lt;span class="no"&gt;Assignment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;data: &lt;/span&gt;&lt;span class="n"&gt;template_testbench&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;suite&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;validate&lt;/span&gt;
  &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:template_testbench&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;suite&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:data&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;to_sentence&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;suite&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;key?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When an assignment's template changes, the suite is copied into a testbench, but only if the assignment does not already have one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;after_save&lt;/span&gt; &lt;span class="ss"&gt;:copy_template_testbench&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;if: :saved_change_to_circuit_template_id?&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;copy_template_testbench&lt;/span&gt;
  &lt;span class="n"&gt;suite&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;circuit_template&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;template_testbench&lt;/span&gt;
  &lt;span class="n"&gt;create_testbench!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;data: &lt;/span&gt;&lt;span class="n"&gt;suite&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;suite&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;present?&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;testbench&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;nil?&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An instructor who has already written their own tests keeps them. Nothing in the interface sets &lt;code&gt;template_testbench&lt;/code&gt; yet, so this is&lt;br&gt;
the plumbing for a later change, and I would rather say that than imply a feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the project stands
&lt;/h2&gt;

&lt;p&gt;Subgroups can be created, filled, shown, progress-tracked, exposed through an API and given a shared project. One of the largest gaps,&lt;br&gt;
a user removed from the parent keeping their team, is real and open, and so is the destructive empty parameter on the web path. Templates&lt;br&gt;
can be saved, browsed, linked to an assignment and copied to a student, with access checked at the copy, and they can carry a&lt;br&gt;
test suite that follows the link.&lt;/p&gt;

&lt;p&gt;The lesson I would write on the wall is that a reference is a promise about later, and every promise about later needs a check at later.&lt;/p&gt;

&lt;p&gt;Next week: template visibility stops being a label on a column and becomes a rule, and a template gets a fork button.&lt;/p&gt;

</description>
      <category>gsoc</category>
      <category>opensource</category>
      <category>security</category>
      <category>api</category>
    </item>
    <item>
      <title>Week 20: A Pull Request That Cannot See Its Prerequisites</title>
      <dc:creator>Akanksha Trehun</dc:creator>
      <pubDate>Thu, 29 Oct 2026 14:12:05 +0000</pubDate>
      <link>https://dev.to/magic-peach/week-20-a-pull-request-that-cannot-see-its-prerequisites-229n</link>
      <guid>https://dev.to/magic-peach/week-20-a-pull-request-that-cannot-see-its-prerequisites-229n</guid>
      <description>&lt;h1&gt;
  
  
  Week 20: A Pull Request That Cannot See Its Prerequisites
&lt;/h1&gt;

&lt;p&gt;Eight pull requests moved this week, five of them new. The grading pipeline got its LMS leg, group rosters got a manual sync, and&lt;br&gt;
subgroups started with a data layer, a listing scope and a policy. The thing that connects all of them is not a feature. It is one&lt;br&gt;
sentence that showed up on &lt;code&gt;#8003&lt;/code&gt;, &lt;code&gt;#8043&lt;/code&gt; and &lt;code&gt;#8048&lt;/code&gt;, in slightly different words each time: &lt;em&gt;the thing this code calls is not on&lt;br&gt;
this branch&lt;/em&gt;. This post is about the two ways I answered it and which one I would not do again.&lt;/p&gt;
&lt;h2&gt;
  
  
  The sentence
&lt;/h2&gt;

&lt;p&gt;CodeRabbit on the roster sync:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;sync_roster&lt;/code&gt; reaches &lt;code&gt;Lti::Membership::SCOPE&lt;/code&gt;. The reviewed head defines &lt;code&gt;Lti::AccessToken&lt;/code&gt;, but it defines neither &lt;code&gt;Lti::Membership&lt;/code&gt;&lt;br&gt;
nor &lt;code&gt;Lti::RosterImport&lt;/code&gt; or &lt;code&gt;Lti::RosterDrop&lt;/code&gt;. The request can therefore raise &lt;code&gt;NameError&lt;/code&gt; and return 500 without syncing.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;On the subgroup controller:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;On the current PR branch, &lt;code&gt;Group&lt;/code&gt; has no &lt;code&gt;subgroups&lt;/code&gt; association, and the schema has no &lt;code&gt;parent_group_id&lt;/code&gt;. A create request therefore&lt;br&gt;
fails at &lt;code&gt;@group.subgroups&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And on the results page from last week, a human reviewer: the model and table it reads were absent, so an authorized request fails at&lt;br&gt;
the lookup.&lt;/p&gt;

&lt;p&gt;The reviewers are right every time, and none of them is wrong to be annoyed. Each branch is cut from master and only contains its own&lt;br&gt;
commits, so a reviewer who checks out the branch sees code that calls something that does not exist. In my head these are links in a&lt;br&gt;
chain. On GitHub each one is a separate pull request that has to make sense by itself.&lt;/p&gt;
&lt;h2&gt;
  
  
  Answer one: put the prerequisites in the branch
&lt;/h2&gt;

&lt;p&gt;On the 12th I handled the two oldest ones by merging the branches they depend on into them. The editor got the testbench model merged&lt;br&gt;
in, and the results page got the settings and the result model. A single commit message captures it: &lt;code&gt;Merge branch&lt;br&gt;
'feat/grading-result' into feat/grading-results-ui&lt;/code&gt;. The branches now run from a clean checkout.&lt;/p&gt;

&lt;p&gt;That is the correct answer to a stack that is going to merge in order, and the price is the diff. A pull request that adds a results&lt;br&gt;
page now shows 21 changed files, and most of them belong to other pull requests. I know which lines are mine. A reviewer has to&lt;br&gt;
work it out. I would only call this a fix in the sense that the CI is green.&lt;/p&gt;

&lt;p&gt;While I was in the editor I fixed a different finding that had been waiting, from a reviewer who read the JavaScript:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;beforeSubmit()&lt;/code&gt; has already converted the deadline to iso when this blocks submission. fixing the pin mismatch and retrying&lt;br&gt;
produces an invalid deadline; validate before converting it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The submit handler rewrote the deadline field into an ISO string and then discovered a problem and refused the submit. The form came&lt;br&gt;
back with the field already rewritten, so the second attempt posted a deadline in the wrong format. The rule is old and I broke it&lt;br&gt;
anyway: validate first, mutate second, so a refusal leaves the form the way the user left it.&lt;/p&gt;

&lt;p&gt;The same day, &lt;code&gt;AutogradeJob&lt;/code&gt; stopped giving fractional credit when &lt;code&gt;partial_credit&lt;/code&gt; is false, which is last week's finding finally&lt;br&gt;
closed, and the results page started drawing one row per test case instead of one row per group. Neither needed a new idea. They needed&lt;br&gt;
the review comment to be read slowly.&lt;/p&gt;
&lt;h2&gt;
  
  
  Answer two: make the code survive the absence
&lt;/h2&gt;

&lt;p&gt;The roster sync and the subgroup controller got a different treatment, and I am less happy with it.&lt;/p&gt;

&lt;p&gt;For the roster action I added a guard in front of the call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;redirect_to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;group_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="vi"&gt;@group&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="ss"&gt;alert: &lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;".not_available"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;roster_services_loaded?&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;roster_services_loaded?&lt;/span&gt;
  &lt;span class="k"&gt;defined?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Lti&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Membership&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;defined?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Lti&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;RosterImport&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;defined?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Lti&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;RosterDrop&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For subgroups, the equivalent on the 18th:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;before_action&lt;/span&gt; &lt;span class="ss"&gt;:check_subgroups_available&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;check_subgroups_available&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="vi"&gt;@group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;respond_to?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:subgroups&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="n"&gt;redirect_to&lt;/span&gt; &lt;span class="n"&gt;group_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="vi"&gt;@group&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="ss"&gt;alert: &lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"subgroups.not_available"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both turn a 500 into a redirect with a message, and both make the branch pass a review that was about the branch. Neither is something&lt;br&gt;
I would write if the prerequisite were on master. After the stack merges, each one is a permanent branch that can never be taken, a&lt;br&gt;
test for a state production will never be in, and a translation key nobody will see. I wrote a feature detection for a feature that is&lt;br&gt;
going to exist, because it was cheaper than answering a reviewer's question by changing the shape of the stack.&lt;/p&gt;

&lt;p&gt;The honest accounting is that answer one costs the reviewer's attention and answer two costs the codebase's. A reviewer's attention is&lt;br&gt;
spent once and the dead branch is paid for by everyone who reads that controller later. My rule for next time is that code which only&lt;br&gt;
exists to survive its own pull request being early gets deleted in the pull request that makes it unnecessary, and that I write that&lt;br&gt;
deletion down in the description so someone knows to look for it.&lt;/p&gt;
&lt;h2&gt;
  
  
  The LMS leg
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;#8029&lt;/code&gt; is the last link in the grading pipeline: once a submission is graded and the assignment came from an LMS, post the score&lt;br&gt;
back to the instructor's gradebook through the AGS line item. It went up on the 14th, and CodeRabbit found two real problems in it&lt;br&gt;
that I had listed last week as unchecked assumptions.&lt;/p&gt;

&lt;p&gt;The first was the rescue that returns &lt;code&gt;nil&lt;/code&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If &lt;code&gt;Lti::Score.publish&lt;/code&gt; fails, this rescue returns &lt;code&gt;nil&lt;/code&gt; without reporting the failure. The caller cannot distinguish a failed&lt;br&gt;
publish from a skipped push, so a transient LMS failure can leave the gradebook without the score.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I had written &lt;code&gt;nil&lt;/code&gt; for both "nothing to push" and "the push failed", so no caller could tell a silent skip from a lost grade. It&lt;br&gt;
returns a boolean now and logs the failure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;Lti&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Score&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lineitem_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;user_id: &lt;/span&gt;&lt;span class="nb"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;score_given: &lt;/span&gt;&lt;span class="n"&gt;given&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;score_maximum: &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kp"&gt;true&lt;/span&gt;
&lt;span class="k"&gt;rescue&lt;/span&gt; &lt;span class="no"&gt;Lti&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;AccessToken&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;Lti&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Score&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Error&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;
  &lt;span class="no"&gt;Rails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Autograder::AgsPush failed for project &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;message&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kp"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second was the single &lt;code&gt;users.lti_sub&lt;/code&gt; column:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;LTI defines &lt;code&gt;sub&lt;/code&gt; as unique within an issuer, not across issuers. If one user launches from two LMS issuers, &lt;code&gt;users.lti_sub&lt;/code&gt; can&lt;br&gt;
retain only one subject. &lt;code&gt;Autograder::AgsPush&lt;/code&gt; can then send that subject to the other issuer's line item.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This one scared me more than the first, because it is a wrong grade going to the wrong place with no error. A subject identifier is&lt;br&gt;
meaningful only together with the platform that issued it. The column became a separate identity per deployment, and the lookup takes&lt;br&gt;
the deployment from the assignment's resource link:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="nb"&gt;sub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;link&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;author&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lti_sub_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lti_deployment&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A third finding, that a stored line item URL should be rejected unless it is HTTPS before the bearer token is sent to it, is the same&lt;br&gt;
cleartext concern CodeRabbit raised on the runner in September. It is still open on the score service.&lt;/p&gt;
&lt;h2&gt;
  
  
  Rosters, and a migration that would have locked a table
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;#8043&lt;/code&gt; adds a Sync Roster button on a group page. It fetches the current course roster from the LMS, adds the members who joined,&lt;br&gt;
drops the ones who left and records when it last succeeded. Until now a roster entered CircuitVerse once, at launch, so a student who&lt;br&gt;
dropped the course stayed in the group. I chose a button over syncing on every launch because a visible button with a visible "last&lt;br&gt;
synced" time is easier to reason about when the LMS is flaky, and an automatic sync would hide failures and slow every launch.&lt;/p&gt;

&lt;p&gt;The migration needed work. strong_migrations refused the foreign key on &lt;code&gt;groups&lt;/code&gt; as one step, so the commit on the 17th split it into&lt;br&gt;
a two step version that does not hold a long lock on the table. It is the shape I now reach for by default, so I will not belabour it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Subgroups begin
&lt;/h2&gt;

&lt;p&gt;The subgroup work started on the 16th and it begins with a choice I am glad I made early: a subgroup is just a &lt;code&gt;Group&lt;/code&gt; with a&lt;br&gt;
&lt;code&gt;parent_group_id&lt;/code&gt;, not a new table. Everything already built for groups, such as members, assignments and the mentor checks, works on a&lt;br&gt;
subgroup for free. The model keeps the rules in one place: one level of nesting, the subgroup takes the parent's mentor, names are&lt;br&gt;
unique within a parent, and creating a subgroup does not send the "new group" email, because sorting a class into teams should not&lt;br&gt;
trigger one email per team.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;scope&lt;/span&gt; &lt;span class="ss"&gt;:top_level&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;parent_group_id: &lt;/span&gt;&lt;span class="kp"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;validates&lt;/span&gt; &lt;span class="ss"&gt;:name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;uniqueness: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;scope: :parent_group_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;case_sensitive: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="ss"&gt;if: :subgroup?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The consequence of that choice showed up the next day in &lt;code&gt;#8046&lt;/code&gt;. A subgroup is a row in &lt;code&gt;groups&lt;/code&gt;, so every "list my groups" query&lt;br&gt;
returns it next to the real classes, on the dashboard and in the API. I filter in SQL with &lt;code&gt;top_level&lt;/code&gt; at each listing, rather than&lt;br&gt;
in the serializer, so pagination counts stay right and later listings get the behaviour from the scope instead of remembering to&lt;br&gt;
filter. A reviewer found the hole in the inheritance rule:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The callback runs only on creation. If &lt;code&gt;Api::V1::GroupsController#update&lt;/code&gt; changes a subgroup's &lt;code&gt;primary_mentor_id&lt;/code&gt;, the subgroup no&lt;br&gt;
longer has its parent's primary mentor.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Inheritance at creation is not an invariant. A rule that is supposed to always hold has to be enforced on update too, including when a&lt;br&gt;
parent's mentor changes and its subgroups are left pointing at the old one. The commit on the 17th makes it hold past creation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the project stands
&lt;/h2&gt;

&lt;p&gt;The grading pipeline is complete as code, from test editor to gradebook. Subgroups have a data layer, a listing scope and a policy with&lt;br&gt;
a controller. Two of the answers to "the prerequisite is missing" are in the code, and I would use only one of them again.&lt;/p&gt;

&lt;p&gt;Next week: the subgroup UI, API and team project go up, and every review is about who is allowed to see what.&lt;/p&gt;

</description>
      <category>gsoc</category>
      <category>opensource</category>
      <category>lti</category>
      <category>security</category>
    </item>
    <item>
      <title>Week 19: What the Student Is Allowed to See</title>
      <dc:creator>Akanksha Trehun</dc:creator>
      <pubDate>Thu, 29 Oct 2026 14:05:16 +0000</pubDate>
      <link>https://dev.to/magic-peach/week-19-what-the-student-is-allowed-to-see-3ice</link>
      <guid>https://dev.to/magic-peach/week-19-what-the-student-is-allowed-to-see-3ice</guid>
      <description>&lt;h1&gt;
  
  
  Week 19: What the Student Is Allowed to See
&lt;/h1&gt;

&lt;p&gt;This was a one pull request week, and I want to say so plainly rather than pad it. &lt;code&gt;#8003&lt;/code&gt; opened on the 10th, a results page for a graded&lt;br&gt;
submission, and the rest of the week went on the next piece of the pipeline, which I will get to at the end. The results page looks like&lt;br&gt;
the simplest thing in the autograding project, a score and a table, and it contained the one design decision in the whole pipeline&lt;br&gt;
where a mistake leaks something the instructor explicitly chose to hide.&lt;/p&gt;
&lt;h2&gt;
  
  
  The problem is not showing the result
&lt;/h2&gt;

&lt;p&gt;A graded submission already has a stored &lt;code&gt;GradingResult&lt;/code&gt;. Until this week it existed only in the database, so a student who was graded&lt;br&gt;
had no way to find out. The new route renders the overall score and a table with one row per test case.&lt;/p&gt;

&lt;p&gt;The harder half is that some test cases are hidden on purpose. An instructor marks a case hidden so students cannot work out the exact&lt;br&gt;
inputs and outputs the grader expects and write a circuit that satisfies the list instead of the problem. If the page simply printed&lt;br&gt;
every stored value, it would give that away. What a page may print and what the database knows are different sets, and the page is the&lt;br&gt;
only thing standing between them.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why the stored result is not enough on its own
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;breakdown&lt;/code&gt; column on a result holds pass or fail for each case, by position, and nothing else. A page built only from it would say&lt;br&gt;
"case 1: passed, case 2: failed" and a student would have no idea what either case tested. Showing the raw JSON had the same problem and&lt;br&gt;
a worse one, since it is unreadable. To show something useful the page needs the input and output pin values, and those live in the&lt;br&gt;
suite, not the result.&lt;/p&gt;

&lt;p&gt;This is where last week's design earns its keep. A result stores a snapshot of the suite as it was when the run happened, so the two&lt;br&gt;
halves come from the same moment and their positions line up. If the page read the live suite, an instructor who edited the suite after&lt;br&gt;
grading would shift every row against its result, and a student would see one test's inputs next to another test's verdict. The helper&lt;br&gt;
that joins them depends on that guarantee and says so by only ever reading the snapshot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;visible_test_groups&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reveal_hidden&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;snapshot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;suite_snapshot&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"groups"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
  &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;breakdown&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each_with_index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flat_map&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
    &lt;span class="n"&gt;hidden&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"hidden"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;reveal_hidden&lt;/span&gt;
    &lt;span class="no"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"cases"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;each_with_index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"label"&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;case_label&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s2"&gt;"passed"&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"passed"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s2"&gt;"hidden"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s2"&gt;"inputs"&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;hidden&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kp"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pin_values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"inputs"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s2"&gt;"outputs"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;hidden&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kp"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pin_values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"outputs"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a hidden case the pins come back &lt;code&gt;nil&lt;/code&gt;, so the view has nothing to print. I put the hiding in the helper rather than in the model on&lt;br&gt;
purpose. &lt;code&gt;GradingResult&lt;/code&gt; records what happened and has no opinion about who is looking, and an instructor who opens the same page with&lt;br&gt;
the reveal setting on gets the full table from the same code.&lt;/p&gt;
&lt;h2&gt;
  
  
  Who can open the page
&lt;/h2&gt;

&lt;p&gt;The action is a member of the project resource, guarded by the same &lt;code&gt;check_view_access&lt;/code&gt; that protects the project itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;before_action&lt;/span&gt; &lt;span class="ss"&gt;:check_view_access&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;only: &lt;/span&gt;&lt;span class="sx"&gt;%i[show create_fork change_stars grading_result]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I did not add a new permission, and it is worth being careful about why that is safe. The grading page reuses the rule for viewing the&lt;br&gt;
project, so whoever can open the submission can open its result. For an assignment project that is a short list, because a project that&lt;br&gt;
belongs to an assignment is not allowed to be public, which the model validation enforces. The audience is the author, the assignment's&lt;br&gt;
mentors and any collaborators. A new rule would have been a second place for the access logic to drift. The cost of reusing the rule is&lt;br&gt;
that the page's privacy is only as good as the project's. The case that matters most is a hidden test case with the reveal setting off,&lt;br&gt;
and the controller spec checks it in both directions: the pin values are absent from the page when reveal is off and present when it is&lt;br&gt;
on.&lt;/p&gt;
&lt;h2&gt;
  
  
  The next piece, started
&lt;/h2&gt;

&lt;p&gt;The other thing I did this week was begin the last link in the chain. Once a submission is graded, the instructor's gradebook should&lt;br&gt;
hear about it when the assignment came from an LMS. &lt;code&gt;Autograder::AgsPush&lt;/code&gt; takes a project and a score, finds the assignment's LTI&lt;br&gt;
resource link and the author's LTI subject, fetches a token and posts the score to the line item:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;:,&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:)&lt;/span&gt;
  &lt;span class="n"&gt;link&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assignment&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lti_resource_link&lt;/span&gt;
  &lt;span class="nb"&gt;sub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;author&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lti_sub&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lineitem_url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;present?&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;present?&lt;/span&gt;

  &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Lti&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;AccessToken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lti_deployment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="no"&gt;Lti&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Score&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;SCOPE&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="ss"&gt;signing_key: &lt;/span&gt;&lt;span class="no"&gt;Lti&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;KeyManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;private_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="no"&gt;Lti&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Score&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lineitem_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;user_id: &lt;/span&gt;&lt;span class="nb"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;score_given: &lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="ss"&gt;score_maximum: &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;rescue&lt;/span&gt; &lt;span class="no"&gt;Lti&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;AccessToken&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;Lti&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Score&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Error&lt;/span&gt;
  &lt;span class="kp"&gt;nil&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It mirrors the existing token and score split so grading code only deals with a project and a score. It also assumes a few things I&lt;br&gt;
had not checked. One of them is stored in a single column, and one of them is a rescue that returns &lt;code&gt;nil&lt;/code&gt;. Both of them get questioned next week.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the project stands
&lt;/h2&gt;

&lt;p&gt;The student side of the loop exists: a graded submission has a page, the page shows each case, and what an instructor hid stays hidden.&lt;br&gt;
It also depends on two other pull requests, one for the result model and one for the reveal setting, and I opened it before deciding&lt;br&gt;
how to deal with that. The instructor side, pushing the grade out to an LMS, is written and has not met a reviewer.&lt;/p&gt;

&lt;p&gt;Next week: the page gets its first review, the LMS side gets its first review, and the same prerequisite comment turns up on three more&lt;br&gt;
pull requests.&lt;/p&gt;

</description>
      <category>gsoc</category>
      <category>opensource</category>
      <category>autograding</category>
      <category>authorization</category>
    </item>
    <item>
      <title>Week 18: Four Small Pieces and the Seams Between Them</title>
      <dc:creator>Akanksha Trehun</dc:creator>
      <pubDate>Thu, 29 Oct 2026 14:04:15 +0000</pubDate>
      <link>https://dev.to/magic-peach/week-18-four-small-pieces-and-the-seams-between-them-2f2f</link>
      <guid>https://dev.to/magic-peach/week-18-four-small-pieces-and-the-seams-between-them-2f2f</guid>
      <description>&lt;h1&gt;
  
  
  Week 18: Four Small Pieces and the Seams Between Them
&lt;/h1&gt;

&lt;p&gt;Four pull requests opened this week, on the 28th, 29th, 29th and 30th, and none of them is more than about a hundred lines. Autograde settings, a&lt;br&gt;
&lt;code&gt;GradingResult&lt;/code&gt; model, &lt;code&gt;AutogradeJob&lt;/code&gt;, and &lt;code&gt;GradeScaleMapper&lt;/code&gt;. Each one is easy to review on its own, and almost every review comment&lt;br&gt;
was about the gap between two of them rather than about the code inside one. A small piece is easy to get right. The seam between two&lt;br&gt;
small pieces belongs to nobody until someone writes it down.&lt;/p&gt;
&lt;h2&gt;
  
  
  Settings that nothing reads
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;#7926&lt;/code&gt; adds three per assignment settings to the form: allow partial credit, a maximum number of attempts, and whether to reveal test&lt;br&gt;
cases before submission. Three columns, a form block, 82 added lines. The reviewer's comment was the one I should have written myself:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;these three settings get saved but nothing reads them yet (not on master, not in #7836/#7906/#7907), so an instructor can set max&lt;br&gt;
attempts to 1 and students still get unlimited runs. i'd add each column in the pr that enforces it, or hide this block until then.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A control that saves and does nothing is worse than a missing control. An instructor sets attempts to one, sees "saved", and believes&lt;br&gt;
something is being enforced. I split the settings out to keep the job pull request small, and the split quietly shipped a promise before&lt;br&gt;
the thing that keeps it.&lt;/p&gt;

&lt;p&gt;I did not want to fold the columns back into the job's pull request, because that undoes the point of splitting. The commit that followed&lt;br&gt;
gates the whole block behind a Flipper flag, so the form does not show it unless autograding is switched on for the&lt;br&gt;
deployment. That keeps the pieces separate and keeps the promise honest: until the enforcing pull requests merge, nobody can make it.&lt;br&gt;
The alternative of adding each column in the pull request that reads it was the reviewer's first suggestion and is the better long term&lt;br&gt;
rule. It would have meant re-cutting the stack, and I would rather say that than pretend the flag is the same thing.&lt;/p&gt;
&lt;h2&gt;
  
  
  A default that defeats its own constraint
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;GradingResult&lt;/code&gt; stores the outcome of one grading attempt: a copy of the suite it ran against, the per test breakdown, and a score&lt;br&gt;
validated to sit between 0 and 100 when present. The reason for the copy is that an instructor can edit a suite after students have been&lt;br&gt;
graded, and a result that only points at the live suite would show a grade next to a test it was never run against.&lt;/p&gt;

&lt;p&gt;CodeRabbit found a flaw in the migration that I would not have found by testing through the model:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;if a database insert omits &lt;code&gt;suite_snapshot&lt;/code&gt;, this default stores &lt;code&gt;{}&lt;/code&gt; instead of rejecting the result. Rails &lt;code&gt;insert_all&lt;/code&gt; skips model&lt;br&gt;
validations, so such a result can be persisted without the suite context needed to interpret it after the live suite changes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I had written the column as &lt;code&gt;null: false, default: {}&lt;/code&gt;, and the default is exactly what lets a bad row in.&lt;br&gt;
Validations live in the model and &lt;code&gt;insert_all&lt;/code&gt; does not call the model, so the only guard that applies to every write path is the one in&lt;br&gt;
the database, and &lt;code&gt;default: {}&lt;/code&gt; switches that guard off. Dropping the default means an insert that forgets the snapshot fails loudly at&lt;br&gt;
the database, which is the whole reason the column exists.&lt;/p&gt;
&lt;h2&gt;
  
  
  The job, and the four questions CodeRabbit asked it
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;AutogradeJob&lt;/code&gt; is the piece that finally connects submission to grade. It is triggered after the deadline job forks a student's project&lt;br&gt;
into a submission, and only when the assignment has a testbench. It runs the runner and stores a &lt;code&gt;GradingResult&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;perform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;project_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;project&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;id: &lt;/span&gt;&lt;span class="n"&gt;project_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;assignment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assignment&lt;/span&gt;
  &lt;span class="n"&gt;testbench&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;assignment&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;testbench&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;testbench&lt;/span&gt;

  &lt;span class="n"&gt;max&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;assignment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max_attempts&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;max&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="no"&gt;GradingResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;project: &lt;/span&gt;&lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;max&lt;/span&gt;

  &lt;span class="k"&gt;begin&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Autograder&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Runner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;project: &lt;/span&gt;&lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;testbench: &lt;/span&gt;&lt;span class="n"&gt;testbench&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;breakdown&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;groups&lt;/span&gt;
    &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;score&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;rescue&lt;/span&gt; &lt;span class="no"&gt;Autograder&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Runner&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;RunnerError&lt;/span&gt;
    &lt;span class="n"&gt;breakdown&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kp"&gt;nil&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="no"&gt;GradingResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;project: &lt;/span&gt;&lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;suite_snapshot: &lt;/span&gt;&lt;span class="n"&gt;testbench&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="ss"&gt;breakdown: &lt;/span&gt;&lt;span class="n"&gt;breakdown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;score: &lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I chose a background job over running the grade in the request because the runner call is a network request with real latency and&lt;br&gt;
timeouts, and a submission should not wait on it. The review found four different ways the seams in this short method fail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It enqueues before the data exists.&lt;/strong&gt; The deadline job calls this from inside &lt;code&gt;assignment.with_lock&lt;/code&gt;. With default settings Rails&lt;br&gt;
enqueues immediately, so a Sidekiq worker can start before the transaction commits, &lt;code&gt;Project.find_by&lt;/code&gt; returns nothing, and the first&lt;br&gt;
line quietly returns. The submission is never graded and nothing says why. The fix is to enqueue after commit, either through&lt;br&gt;
&lt;code&gt;enqueue_after_transaction_commit&lt;/code&gt; on the job or an after commit callback.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An outage uses up an attempt.&lt;/strong&gt; If the runner is down, the &lt;code&gt;rescue&lt;/code&gt; stores a result with a &lt;code&gt;nil&lt;/code&gt; score. With &lt;code&gt;max_attempts&lt;/code&gt; at one,&lt;br&gt;
that row is counted by the next run's limit check, so a student who submitted once and hit an outage can never be graded. A failed run&lt;br&gt;
is not an attempt the student made.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It can run twice.&lt;/strong&gt; Sidekiq can execute the same job more than once. Two executions can both pass the count check before either has&lt;br&gt;
written a row, so one submission ends up with two results and exceeds its attempt limit. The count and the write are not one atomic&lt;br&gt;
step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It ignores a setting I had just added.&lt;/strong&gt; The reviewer noticed that with &lt;code&gt;partial_credit: false&lt;/code&gt; from &lt;code&gt;#7926&lt;/code&gt;, one passing case out of&lt;br&gt;
two still stores 50 percent. The job was written before the setting existed, and neither pull request mentions the other. The fix&lt;br&gt;
landed in a follow up commit on the 12th, a few lines that turn a partial score into all or nothing.&lt;/p&gt;

&lt;p&gt;Two of those four are problems with the job. The other two, the outage and the partial credit rule, are problems with what the job&lt;br&gt;
assumes the neighbouring pieces mean. That ratio is the point of this week.&lt;/p&gt;
&lt;h2&gt;
  
  
  Making the raw number mean something
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;AutogradeJob&lt;/code&gt; stores a fraction between 0 and 1, and a stored 0.75 does not say whether the assignment is graded on a percentage, a&lt;br&gt;
letter scale or not at all. &lt;code&gt;GradeScaleMapper&lt;/code&gt; converts it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;LETTER_THRESHOLDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"A"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"B"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;70&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"C"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"D"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"E"&lt;/span&gt;&lt;span class="p"&gt;]].&lt;/span&gt;&lt;span class="nf"&gt;freeze&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;assignment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fraction&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;mappable?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;assignment&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="n"&gt;percent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fraction&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;scale_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;assignment&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="s2"&gt;"letter"&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="no"&gt;LETTER_THRESHOLDS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;min&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;percent&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;min&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;last&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s2"&gt;"F"&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;percent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_s&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scale_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;assignment&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;assignment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lti_integrated?&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;"percent"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;assignment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;grading_scale&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An LTI integrated assignment is always treated as a percentage, whatever its &lt;code&gt;grading_scale&lt;/code&gt; says, because that is the constraint the&lt;br&gt;
LMS side already imposes. A grade that would have to be a letter in one system and a number in the other is a bug waiting for a&lt;br&gt;
gradebook. &lt;code&gt;no_scale&lt;/code&gt; returns &lt;code&gt;nil&lt;/code&gt;, and the job checks &lt;code&gt;GradeScaleMapper.mappable?&lt;/code&gt; before it runs the runner at all, since there is&lt;br&gt;
no point spending a simulator call on an assignment that will not be graded.&lt;/p&gt;

&lt;p&gt;I considered storing the mapped grade on the result row instead of computing it. I kept the raw fraction because the scale is an&lt;br&gt;
assignment setting that an instructor can change, and the stored data should not go stale when they do.&lt;/p&gt;
&lt;h2&gt;
  
  
  The runner's fixes
&lt;/h2&gt;

&lt;p&gt;The two configuration findings from last week landed on the 29th. The default URL is gone, so a missing &lt;code&gt;SIMULATOR_RUNNER_URL&lt;/code&gt; raises&lt;br&gt;
&lt;code&gt;KeyError&lt;/code&gt; instead of posting to localhost, and the request now carries a bearer token from &lt;code&gt;SIMULATOR_RUNNER_TOKEN&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;HTTP&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="no"&gt;TIMEOUTS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Bearer &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SIMULATOR_RUNNER_TOKEN'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;json: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;circuit: &lt;/span&gt;&lt;span class="n"&gt;circuit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;testbench: &lt;/span&gt;&lt;span class="n"&gt;testbench&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response validation findings, the &lt;code&gt;null&lt;/code&gt; body and the string &lt;code&gt;"false"&lt;/code&gt;, are still open.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the project stands
&lt;/h2&gt;

&lt;p&gt;The pipeline now exists end to end as code: a suite is authored, a submission triggers a job, the job calls the runner, the result is&lt;br&gt;
stored with the suite it ran against, and the score can be expressed on the scale the assignment uses. It does not yet exist as&lt;br&gt;
something I would trust, because the seams I listed are mostly still open: enqueue after commit, outage versus attempt, idempotency.&lt;br&gt;
Those are not hard fixes. They are the fixes you only find by reading the code next to its neighbours.&lt;/p&gt;

&lt;p&gt;Next week: a student sees the result, and the first question is how much of the suite they are allowed to see.&lt;/p&gt;

</description>
      <category>gsoc</category>
      <category>opensource</category>
      <category>autograding</category>
      <category>security</category>
    </item>
    <item>
      <title>Week 17: A Client That Fails Loud</title>
      <dc:creator>Akanksha Trehun</dc:creator>
      <pubDate>Thu, 29 Oct 2026 14:03:14 +0000</pubDate>
      <link>https://dev.to/magic-peach/week-17-a-client-that-fails-loud-57a6</link>
      <guid>https://dev.to/magic-peach/week-17-a-client-that-fails-loud-57a6</guid>
      <description>&lt;h1&gt;
  
  
  Week 17: A Client That Fails Loud
&lt;/h1&gt;

&lt;p&gt;Two pull requests opened this week, the runner on the 23rd and the test case&lt;br&gt;
editor on the 24th, and between them they taught the same lesson from opposite&lt;br&gt;
directions. The runner taught me that a client for a service nobody has built&lt;br&gt;
yet has to be stricter than a client for a service that exists, because&lt;br&gt;
nothing downstream will catch what it lets through. The editor taught me that&lt;br&gt;
a pull request which leans on an unmerged one fails in CI before a reviewer&lt;br&gt;
reads a single line of it.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the runner is, and what it refuses to be
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Autograder::Runner&lt;/code&gt; is 159 lines across three files and it does one thing:&lt;br&gt;
it posts a student's circuit and a test suite to an external simulator&lt;br&gt;
service and turns the answer into a pass count. It does not touch the&lt;br&gt;
database. It does not decide what a score means. It is the messenger between&lt;br&gt;
Rails and the thing that can actually simulate a circuit, because the&lt;br&gt;
simulator lives entirely in the browser and Rails has no way to run a circuit&lt;br&gt;
by itself.&lt;/p&gt;

&lt;p&gt;I considered a headless browser per submission and dropped it, since booting&lt;br&gt;
a full frontend to grade one circuit is too heavy to do on every run. A&lt;br&gt;
background queue was the other option I set aside. The job that calls the&lt;br&gt;
runner is its own pull request, so the runner stays a plain object that&lt;br&gt;
either returns a result or raises.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RunnerError&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;StandardError&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="no"&gt;TIMEOUTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;connect: &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;write: &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;read: &lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="nf"&gt;freeze&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;
  &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;HTTP&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="no"&gt;TIMEOUTS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                 &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;json: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;circuit: &lt;/span&gt;&lt;span class="n"&gt;circuit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;testbench: &lt;/span&gt;&lt;span class="n"&gt;testbench&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="no"&gt;RunnerError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"Runner returned &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;success?&lt;/span&gt;

  &lt;span class="no"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;rescue&lt;/span&gt; &lt;span class="no"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Error&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;
  &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="no"&gt;RunnerError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"Runner unreachable: &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;message&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;rescue&lt;/span&gt; &lt;span class="no"&gt;JSON&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;ParserError&lt;/span&gt;
  &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="no"&gt;RunnerError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"Runner returned invalid JSON"&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every failure path ends in one exception class, so a caller has one thing to&lt;br&gt;
rescue. That was the design. The reviews were about the places where it&lt;br&gt;
quietly was not true.&lt;/p&gt;
&lt;h2&gt;
  
  
  A response that is valid JSON is not a valid response
&lt;/h2&gt;

&lt;p&gt;The sharpest finding came from CodeRabbit, on how the answer gets scored:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;a successful response containing &lt;code&gt;null&lt;/code&gt; raises &lt;code&gt;NoMethodError&lt;/code&gt; instead of&lt;br&gt;
&lt;code&gt;RunnerError&lt;/code&gt;. a response containing &lt;code&gt;{"groups":[{}]}&lt;/code&gt; returns a zero&lt;br&gt;
score, while a case with &lt;code&gt;"passed":"false"&lt;/code&gt; counts as passed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I had checked that the body parsed and that &lt;code&gt;groups&lt;/code&gt; was a non empty array,&lt;br&gt;
and stopped there. The scoring line counts a case as passed when&lt;br&gt;
&lt;code&gt;kase["passed"]&lt;/code&gt; is truthy, and in Ruby the string &lt;code&gt;"false"&lt;/code&gt; is truthy. A&lt;br&gt;
runner that serialised its booleans as strings would have turned every failing&lt;br&gt;
case into a passing one and handed a student full marks. The finding's last&lt;br&gt;
sentence is the rule I am taking from it: malformed responses must not become&lt;br&gt;
grades.&lt;/p&gt;

&lt;p&gt;The same review pointed at the timeouts. &lt;code&gt;connect&lt;/code&gt;, &lt;code&gt;write&lt;/code&gt; and &lt;code&gt;read&lt;/code&gt; are&lt;br&gt;
per operation limits. A service that keeps trickling bytes just inside the&lt;br&gt;
sixty second read window can hold a Sidekiq worker for as long as it likes,&lt;br&gt;
because nothing bounds the whole request. Per operation timeouts and a total&lt;br&gt;
deadline are different protections and I had only written the first kind.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fallback that would have posted student work to localhost
&lt;/h2&gt;

&lt;p&gt;A human reviewer found the one I am least comfortable with. The endpoint was&lt;br&gt;
built like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SIMULATOR_RUNNER_URL'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;DEFAULT_URL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/run"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;with &lt;code&gt;DEFAULT_URL&lt;/code&gt; set to the loopback address the development runner uses.&lt;br&gt;
The comment on it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;with this fallback a prod box missing &lt;code&gt;SIMULATOR_RUNNER_URL&lt;/code&gt; silently posts&lt;br&gt;
student circuits to its own localhost and every run just shows as&lt;br&gt;
"unreachable".&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is a configuration mistake that produces no error you would look at. The&lt;br&gt;
symptom is an outage that looks like the simulator being down, and the cost is&lt;br&gt;
student circuits going to a port on the same machine. A default is helpful in&lt;br&gt;
development and dangerous in production, and &lt;code&gt;.env.example&lt;/code&gt; already covers&lt;br&gt;
development. The fix is to drop the default so a missing variable raises&lt;br&gt;
&lt;code&gt;KeyError&lt;/code&gt; on the first call instead of on the first complaint.&lt;/p&gt;

&lt;p&gt;The same reviewer asked the next question before I did: the request carries no&lt;br&gt;
authentication, so anything that can reach the runner can make it simulate&lt;br&gt;
arbitrary circuits, and is a shared secret planned for when the service&lt;br&gt;
lands? It was not planned. It is now, as a bearer token read from the&lt;br&gt;
environment in the same strict way. CodeRabbit followed up with the logical&lt;br&gt;
conclusion: a bearer token sent over plain &lt;code&gt;http://&lt;/code&gt; to a non loopback host is&lt;br&gt;
sent in cleartext along with the circuit and the suite, so non loopback URLs&lt;br&gt;
should have to be HTTPS.&lt;/p&gt;
&lt;h2&gt;
  
  
  The contract only one side has seen
&lt;/h2&gt;

&lt;p&gt;The most useful comment was not about any line:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;the response shape (&lt;code&gt;groups[].cases[].passed&lt;/code&gt;) is only defined by this&lt;br&gt;
client and the stubbed spec, there's no runner service in the repo or an&lt;br&gt;
open pr yet. i'd land this with or after the service so the contract comes&lt;br&gt;
from the side that actually simulates.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I wrote the specs against a stub that I also wrote, which means the test and&lt;br&gt;
the code agree with each other and with nothing else. If the real service&lt;br&gt;
names a field differently, every spec still passes and the first real run&lt;br&gt;
fails. I cannot fix that inside this pull request. The honest options are to land&lt;br&gt;
the runner together with the service, or to say plainly in the description&lt;br&gt;
that the response shape is a proposal, and the second is cheaper to do this&lt;br&gt;
week. It also means the strict response checks above matter more, because&lt;br&gt;
they are the only thing standing between a wrong shape and a wrong grade.&lt;/p&gt;

&lt;p&gt;One more review item belongs to the specs: they stubbed only the localhost&lt;br&gt;
URL, so setting &lt;code&gt;SIMULATOR_RUNNER_URL&lt;/code&gt; in a test environment would make the&lt;br&gt;
request miss WebMock and fail. The endpoint in the spec should be derived from&lt;br&gt;
the same configuration as the service.&lt;/p&gt;
&lt;h2&gt;
  
  
  The pull request that failed before anyone read it
&lt;/h2&gt;

&lt;p&gt;The editor went up on the 24th as &lt;code&gt;#7907&lt;/code&gt;, 523 lines over 13 files, and CI&lt;br&gt;
failed with &lt;code&gt;Missing model class Testbench&lt;/code&gt;. The cause was plain&lt;br&gt;
once I looked. The editor edits a &lt;code&gt;Testbench&lt;/code&gt;, and the model that defines it&lt;br&gt;
is a separate pull request that was still open, so on a branch cut from master&lt;br&gt;
the class does not exist. A reviewer's comment said the same thing and added&lt;br&gt;
the second half: both pull requests declare the same &lt;code&gt;has_one :testbench&lt;/code&gt;&lt;br&gt;
line, so they would conflict the moment either merged.&lt;/p&gt;

&lt;p&gt;The fix is the one the stack already implied, which is to build the editor on&lt;br&gt;
top of the model's branch and keep a single declaration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;has_one&lt;/span&gt; &lt;span class="ss"&gt;:testbench&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;dependent: :destroy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;autosave: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I had split the work into small pull requests to keep each one reviewable,&lt;br&gt;
and I had not paid for the split. A small pull request that cannot run alone&lt;br&gt;
is not small for the reviewer, because they have to reconstruct what it&lt;br&gt;
depends on. Stacking is honest about that and cutting from master hides it.&lt;br&gt;
That cost is going to come back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the project stands
&lt;/h2&gt;

&lt;p&gt;The runner's configuration and authentication findings are settled in&lt;br&gt;
principle: no silent default, a shared secret, one exception class. The&lt;br&gt;
response validation findings are the open half, and they are the ones that&lt;br&gt;
decide whether a grade can be trusted. The editor is stacked on the model it&lt;br&gt;
depends on.&lt;/p&gt;

&lt;p&gt;Next week: the runner's fixes land, and the three pieces that turn its answer&lt;br&gt;
into a stored, scaled, rate limited grade start going up.&lt;/p&gt;

</description>
      <category>gsoc</category>
      <category>autograding</category>
      <category>opensource</category>
      <category>security</category>
    </item>
    <item>
      <title>Week 16: The Editor That Learned When to Say No</title>
      <dc:creator>Akanksha Trehun</dc:creator>
      <pubDate>Thu, 01 Oct 2026 12:29:03 +0000</pubDate>
      <link>https://dev.to/magic-peach/week-16-the-editor-that-learned-when-to-say-no-36c2</link>
      <guid>https://dev.to/magic-peach/week-16-the-editor-that-learned-when-to-say-no-36c2</guid>
      <description>&lt;h1&gt;
  
  
  Week 16: The Editor That Learned When to Say No
&lt;/h1&gt;

&lt;p&gt;With the testbench model and the runner both fixed and moving, the test case&lt;br&gt;
editor finally got its own review round this week. It found the same class&lt;br&gt;
of bug the model found last week, in the client side JSON this time instead&lt;br&gt;
of the server side validation, and fixing it properly meant redesigning a&lt;br&gt;
piece of the editor rather than patching around the symptom. A second,&lt;br&gt;
unrelated bug turned up in a completely different pull request, caught only&lt;br&gt;
because a setting this phase just added made an old code path reachable for&lt;br&gt;
the first time.&lt;/p&gt;
&lt;h2&gt;
  
  
  The overwrite that would have erased a title on every save
&lt;/h2&gt;

&lt;p&gt;First finding, on &lt;code&gt;Assignment#testbench_data=&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;testbench&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;build_testbench&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parsed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This replaces the entire &lt;code&gt;data&lt;/code&gt; hash with whatever the editor's JSON posts.&lt;br&gt;
The editor only ever authors &lt;code&gt;type&lt;/code&gt; and &lt;code&gt;groups&lt;/code&gt;. If an existing suite had&lt;br&gt;
any other top level key, a &lt;code&gt;title&lt;/code&gt;, say, something the simulator itself reads&lt;br&gt;
at &lt;code&gt;testbench.js:872&lt;/code&gt;, it survives right up until the next time an instructor&lt;br&gt;
edits an unrelated setting and saves the form. Then it is gone, not because&lt;br&gt;
anyone touched it, but because the write path never preserved what it was not&lt;br&gt;
explicitly given.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;tb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;testbench&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;build_testbench&lt;/span&gt;
&lt;span class="n"&gt;tb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Merge instead of replace. I wrote a regression test that seeds a suite with a&lt;br&gt;
&lt;code&gt;title&lt;/code&gt;, saves an unrelated change through the editor, and asserts the title&lt;br&gt;
survives, then deleted the merge to confirm the test catches the loss. It&lt;br&gt;
does.&lt;/p&gt;
&lt;h2&gt;
  
  
  The same binding bug, one layer up
&lt;/h2&gt;

&lt;p&gt;The second finding was the model's &lt;code&gt;groups[0]&lt;/code&gt; binding problem again, except&lt;br&gt;
this time the mismatched pins would come from the editor itself, from an&lt;br&gt;
instructor adding a second test case row with different inputs than the&lt;br&gt;
first:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;each row becomes its own group, but the simulator binds pins from&lt;br&gt;
&lt;code&gt;groups[0]&lt;/code&gt; only. a row with a different pin set than the first makes&lt;br&gt;
&lt;code&gt;group.outputs.find(...).results.push&lt;/code&gt; return undefined and throw.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Same root cause as last week, different code path producing the malformed&lt;br&gt;
suite. The fix is a shape comparison in the JavaScript controller, mirroring&lt;br&gt;
what the Ruby model already does server side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;sameShape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;shape&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;group&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;inputs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;outputs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;side&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;group&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;side&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bitWidth&lt;/span&gt;&lt;span class="p"&gt;])));&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;every&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;group&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;group&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nf"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Submission blocks client side now if any row's pins do not match the first&lt;br&gt;
row's, with the same pattern used for incomplete rows: &lt;code&gt;preventDefault&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;stopPropagation&lt;/code&gt; because rails-ujs still disables the submit button on a&lt;br&gt;
merely prevented submit as it bubbles to its document level listener, show&lt;br&gt;
the error, stop.&lt;/p&gt;

&lt;p&gt;A third finding, a values regex that accepted any non-empty string instead&lt;br&gt;
of exact-width binary digits, was the exact bug from last week's model&lt;br&gt;
review, just not yet enforced in the form that produces the JSON in the&lt;br&gt;
first place. Tightened the same way: &lt;code&gt;[01]+&lt;/code&gt;, checked against the declared&lt;br&gt;
bit width.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why I did not try to merge the unsupported case in
&lt;/h2&gt;

&lt;p&gt;The harder finding was about suites the editor cannot fully represent:&lt;br&gt;
multi vector test cases, where one row of the JSON actually encodes several&lt;br&gt;
values per pin instead of one. My first fix tried to preserve these by&lt;br&gt;
tracking each group's original array position, splitting edited groups from&lt;br&gt;
untouched ones, and re-merging them back into the original order on save.&lt;br&gt;
It worked, but CodeRabbit found two more bugs in that merge logic within&lt;br&gt;
a day: the merge could still reorder an unsupported group relative to an&lt;br&gt;
edited one, and it silently rewrote the suite's &lt;code&gt;type&lt;/code&gt; from &lt;code&gt;seq&lt;/code&gt; to &lt;code&gt;comb&lt;/code&gt;&lt;br&gt;
on every save because the serialization always hardcoded &lt;code&gt;comb&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two fixes for a problem that a different design does not have in the first&lt;br&gt;
place made me stop and reconsider the approach rather than the code. The&lt;br&gt;
editor cannot safely round trip a multi vector suite. Trying to merge it&lt;br&gt;
back together precisely is solving a harder problem than the feature needs:&lt;br&gt;
tracking position, preserving type, reconciling two sources of truth. The&lt;br&gt;
simpler rule is to never let the editor touch what it cannot fully&lt;br&gt;
represent. If any group in a suite has more than one value per pin, the&lt;br&gt;
whole editor goes read only for that assignment, with a note saying why,&lt;br&gt;
instead of rendering an editable interface that would corrupt the suite the&lt;br&gt;
moment someone saved through it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_case_editable?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;assignment&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;test_case_groups&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;assignment&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;all?&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"n"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"inputs"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;all?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;pin&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="no"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pin&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"values"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"outputs"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;all?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;pin&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="no"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pin&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"values"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This deleted the position tracking, the merge-back logic, and every test&lt;br&gt;
written against it, and replaced all of it with one predicate and an&lt;br&gt;
&lt;code&gt;if/else&lt;/code&gt; in the view. The line count dropped by about sixty lines for a&lt;br&gt;
bigger guarantee: nothing this editor cannot represent can be silently&lt;br&gt;
damaged by it, because it is never editable in the first place.&lt;/p&gt;
&lt;h2&gt;
  
  
  Getting back under budget without cutting a fix
&lt;/h2&gt;

&lt;p&gt;This phase holds pull requests to roughly two hundred hand written lines.&lt;br&gt;
Chasing every one of these findings, plus the accessibility fixes an&lt;br&gt;
automated check flagged separately, pushed this diff past four hundred at&lt;br&gt;
its peak. Cutting it back down meant finding the difference between a line&lt;br&gt;
that is load bearing and a line that is just verbose.&lt;/p&gt;

&lt;p&gt;Consolidating five near identical validation specs into one table driven&lt;br&gt;
test recovered real lines without losing a single case. Dropping a helper&lt;br&gt;
spec file for a follow up PR, per this phase's own stated rule about&lt;br&gt;
splitting spec edge cases rather than thinning validation, recovered more.&lt;br&gt;
The read only redesign recovered the most of all, because it deleted code&lt;br&gt;
that existed to solve a problem a different design does not have. What did&lt;br&gt;
not move: the testbench model's shape and label validation, the values&lt;br&gt;
regex, the malformed pin token check. Those stayed exactly as strict as the&lt;br&gt;
reviews asked for, because a line count is a budget for how a feature is&lt;br&gt;
built, not a ceiling on whether a reported bug gets fixed.&lt;/p&gt;
&lt;h2&gt;
  
  
  The bug that was always possible and just became reachable
&lt;/h2&gt;

&lt;p&gt;A different pull request from this same run, the one adding autograde&lt;br&gt;
settings like max attempts, got a review comment that had nothing to do with&lt;br&gt;
any of the new columns:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;when a mentor submits &lt;code&gt;max_attempts: 0&lt;/code&gt;, the new validation rejects&lt;br&gt;
&lt;code&gt;@assignment.update&lt;/code&gt;. the action renders &lt;code&gt;:edit&lt;/code&gt;, but&lt;br&gt;
&lt;code&gt;after_action :check_reopening_status&lt;/code&gt; still runs. if the assignment has a&lt;br&gt;
forked submission, &lt;code&gt;Assignment#check_reopening_status&lt;/code&gt; can move its&lt;br&gt;
assignment association and destroy the fork despite the failed update.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;code&gt;after_action&lt;/code&gt; callbacks run regardless of whether the action they follow&lt;br&gt;
succeeded. This one always had that property. It just never mattered before,&lt;br&gt;
because almost nothing about updating an assignment used to fail validation.&lt;br&gt;
Adding a &lt;code&gt;max_attempts&lt;/code&gt; column with an actual numericality check gave&lt;br&gt;
mentors, for the first time, an easy way to submit a rejected update, which&lt;br&gt;
is the one condition this pre-existing callback was never guarded against.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;check_reopening_status&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="vi"&gt;@assignment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;present?&lt;/span&gt;

  &lt;span class="vi"&gt;@assignment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;check_reopening_status&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two lines. I reverted them and reran the new regression test to confirm a&lt;br&gt;
rejected update really does destroy a student's forked submission without&lt;br&gt;
the guard, which it does, then restored the fix and confirmed it survives.&lt;br&gt;
The bug was not introduced this phase. The conditions for triggering it&lt;br&gt;
were.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the project stands
&lt;/h2&gt;

&lt;p&gt;Three pull requests carry review fixes now instead of open findings: the&lt;br&gt;
testbench model, the runner, and the test case editor, the last one smaller&lt;br&gt;
and safer than when it went up. The autograde settings pull request gained&lt;br&gt;
an unrelated but real fix along the way. Underneath all of it, the actual&lt;br&gt;
grading pipeline, a result model that snapshots the suite it ran against&lt;br&gt;
rather than pointing at the live one, a job that calls the runner and&lt;br&gt;
respects max attempts, a mapper from raw score to whatever scale the&lt;br&gt;
assignment actually uses, and a results page that redacts hidden test cases&lt;br&gt;
unless an instructor has chosen to reveal them, went up this week as well.&lt;/p&gt;

&lt;p&gt;Next week: watching whether any of these five pull requests actually merge,&lt;br&gt;
and what the first one to land changes for everything still stacked behind&lt;br&gt;
it.&lt;/p&gt;

</description>
      <category>gsoc</category>
      <category>circuitverse</category>
      <category>opensource</category>
      <category>selflearning</category>
    </item>
    <item>
      <title>Week 15: What the Simulator Actually Binds</title>
      <dc:creator>Akanksha Trehun</dc:creator>
      <pubDate>Thu, 01 Oct 2026 12:26:33 +0000</pubDate>
      <link>https://dev.to/magic-peach/week-15-what-the-simulator-actually-binds-1l5i</link>
      <guid>https://dev.to/magic-peach/week-15-what-the-simulator-actually-binds-1l5i</guid>
      <description>&lt;h1&gt;
  
  
  Week 15: What the Simulator Actually Binds
&lt;/h1&gt;

&lt;p&gt;&lt;code&gt;#7746&lt;/code&gt; merged clean, rebase and all, closing out last week's story. That&lt;br&gt;
freed the week for the thing I actually promised: picking the test case&lt;br&gt;
editor back up. Except the editor cannot be evaluated on its own terms until&lt;br&gt;
its two dependencies, the testbench model (&lt;code&gt;#7836&lt;/code&gt;) and the runner (&lt;code&gt;#7906&lt;/code&gt;),&lt;br&gt;
are actually mergeable, and both came back from review this week with&lt;br&gt;
findings that needed fixing before either could move.&lt;/p&gt;
&lt;h2&gt;
  
  
  A label that is not a string
&lt;/h2&gt;

&lt;p&gt;CodeRabbit's first flag on the testbench model was on the signal validator:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;validate_signal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cases&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"every signal needs a label"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_a?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Hash&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;present?&lt;/span&gt;
  &lt;span class="o"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;present?&lt;/code&gt; rejects &lt;code&gt;nil&lt;/code&gt;, empty strings, and whitespace-only strings. It does&lt;br&gt;
not reject &lt;code&gt;1&lt;/code&gt;. A JSON body with &lt;code&gt;"label": 1&lt;/code&gt; sails through this check, gets&lt;br&gt;
stored, and later reaches the simulator, which calls &lt;code&gt;.trim()&lt;/code&gt; on every&lt;br&gt;
signal's label while binding pins to the circuit. Numbers do not have a&lt;br&gt;
&lt;code&gt;.trim()&lt;/code&gt; method in JavaScript. The failure is not "this test case behaves&lt;br&gt;
oddly," it is a &lt;code&gt;TypeError&lt;/code&gt; that kills the run.&lt;/p&gt;

&lt;p&gt;The fix is one clause:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"every signal needs a label"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_a?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Hash&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;is_a?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;present?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Small, but it is the kind of small that only shows up if you trace the value&lt;br&gt;
forward into the file that actually consumes it, rather than stopping at "the&lt;br&gt;
model validates its own shape."&lt;/p&gt;
&lt;h2&gt;
  
  
  The binding a human reviewer caught that a linter would not
&lt;/h2&gt;

&lt;p&gt;The second finding was not from CodeRabbit. A reviewer left this on the same&lt;br&gt;
file:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;nothing checks that later groups use the same input/output labels as&lt;br&gt;
&lt;code&gt;groups[0]&lt;/code&gt;. the simulator binds pins from &lt;code&gt;groups[0]&lt;/code&gt; only&lt;br&gt;
(testbench.js:818-823) and then does &lt;code&gt;inputs[label].state&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;group.outputs.find(...).results.push&lt;/code&gt; per group, so a suite that passes&lt;br&gt;
this validator can throw a TypeError mid-run.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I went and read &lt;code&gt;testbench.js&lt;/code&gt; before touching anything, because the claim is&lt;br&gt;
specific enough to verify rather than trust. It is exactly right:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;bindIO&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inputs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
    &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;inputs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;dataInput&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;inputs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;dataInput&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(...);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only &lt;code&gt;data.groups[0]&lt;/code&gt; ever gets read here. Every other group is bound&lt;br&gt;
against whatever &lt;code&gt;bindIO&lt;/code&gt; built from the first one. If group two declares an&lt;br&gt;
input labeled &lt;code&gt;c&lt;/code&gt; that group one never had, &lt;code&gt;inputs['c']&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt;, and&lt;br&gt;
&lt;code&gt;setInputValues&lt;/code&gt; dies trying to set &lt;code&gt;.state&lt;/code&gt; on it. The model's own&lt;br&gt;
validation, one group at a time, had no way to catch this, because the bug is&lt;br&gt;
not in any single group, it is in the relationship between groups.&lt;/p&gt;

&lt;p&gt;The fix compares each group after the first against the first's shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;validate_group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reference&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;...&lt;/span&gt;
  &lt;span class="k"&gt;next&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;reference&lt;/span&gt;

  &lt;span class="n"&gt;shape&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"bitWidth"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;next&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signals&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reference&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;side&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

  &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;side&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; must match the first group's signals"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Values that are the right length and the wrong alphabet
&lt;/h2&gt;

&lt;p&gt;Same reviewer, same file, a second finding: values were only length checked&lt;br&gt;
against the case count, never checked for actually being binary digits. The&lt;br&gt;
simulator feeds inputs through &lt;code&gt;parseInt(v, 2)&lt;/code&gt; and compares outputs against&lt;br&gt;
&lt;code&gt;dec2bin(value, bitWidth)&lt;/code&gt; as strings. &lt;code&gt;[0, 1]&lt;/code&gt; as integers, &lt;code&gt;"2"&lt;/code&gt; on a one&lt;br&gt;
bit pin, all of it passes the old check and then fails every single case at&lt;br&gt;
run time, silently, which reads to a student as "I got every case wrong"&lt;br&gt;
rather than "the test data itself is malformed."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;valid_values?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cases&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_a?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;size&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;cases&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_a?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_a?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Integer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/\A[01]{&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sr"&gt;}\z/&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exact width, exact alphabet, exact type. I added a regression case for a&lt;br&gt;
numeric label, one for wrong-width values, and one for a second group with a&lt;br&gt;
mismatched pin set, then reverted each fix in turn to confirm its matching&lt;br&gt;
test actually fails without it. All three did. A test that cannot fail is&lt;br&gt;
not a test, it is a comment that happens to run.&lt;/p&gt;
&lt;h2&gt;
  
  
  The runner's two findings were a different flavor entirely
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;#7906&lt;/code&gt;, the runner, got two findings that had nothing to do with the shape&lt;br&gt;
of any data structure:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;with this fallback a prod box missing &lt;code&gt;SIMULATOR_RUNNER_URL&lt;/code&gt; silently posts&lt;br&gt;
student circuits to its own localhost and every run just shows as&lt;br&gt;
"unreachable".&lt;br&gt;
&lt;/p&gt;


&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;endpoint&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SIMULATOR_RUNNER_URL'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;DEFAULT_URL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/run"&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The default was meant for local development convenience and quietly doubled&lt;br&gt;
as a production misconfiguration that fails silent instead of loud. Dropping&lt;br&gt;
the fallback entirely, &lt;code&gt;ENV.fetch('SIMULATOR_RUNNER_URL')&lt;/code&gt; with no second&lt;br&gt;
argument, means a missing variable raises immediately instead of routing&lt;br&gt;
every grading attempt to a service that was never there.&lt;/p&gt;

&lt;p&gt;The second finding was that the request carries no authentication at all,&lt;br&gt;
so anything that can reach the runner can make it simulate arbitrary&lt;br&gt;
circuits. I added a bearer token, &lt;code&gt;ENV.fetch('SIMULATOR_RUNNER_TOKEN')&lt;/code&gt;, sent&lt;br&gt;
on every request. The runner service on the other end still needs to&lt;br&gt;
actually check it, which is a different pull request's job, but the caller&lt;br&gt;
no longer has an excuse not to send one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two different ways of being right
&lt;/h2&gt;

&lt;p&gt;The testbench findings came from reading JavaScript nobody asked the model&lt;br&gt;
to know about. The runner findings came from reading the word "fallback" and&lt;br&gt;
asking what happens when the thing it falls back to is wrong. Neither is&lt;br&gt;
something a type checker or a schema validator would have caught on its&lt;br&gt;
own; both needed someone to actually trace a value from where it is written&lt;br&gt;
to where it is read. That is becoming the throughline of this whole review&lt;br&gt;
cycle: static analysis catches shapes, a reader who follows the data catches&lt;br&gt;
contracts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the project stands
&lt;/h2&gt;

&lt;p&gt;Both the testbench model and the runner have their review fixes pushed, all&lt;br&gt;
cases verified failing without their fix and passing with it. The test case&lt;br&gt;
editor is still sitting locally, unstacked, waiting for these two to actually&lt;br&gt;
merge before its own compare page can show one clean commit instead of three&lt;br&gt;
borrowed ones.&lt;/p&gt;

&lt;p&gt;Next week: the editor's own review round, which turns out to have found the&lt;br&gt;
same class of bug in the client side JSON that this week found in the model,&lt;br&gt;
plus one unrelated bug that only became reachable because of a setting this&lt;br&gt;
phase never had before.&lt;/p&gt;

</description>
      <category>gsoc</category>
      <category>circuitverse</category>
      <category>opensource</category>
      <category>programming</category>
    </item>
    <item>
      <title>Week 14: The Approved Pull Request That Stopped Passing</title>
      <dc:creator>Akanksha Trehun</dc:creator>
      <pubDate>Tue, 08 Sep 2026 17:38:43 +0000</pubDate>
      <link>https://dev.to/magic-peach/week-14-the-approved-pull-request-that-stopped-passing-eln</link>
      <guid>https://dev.to/magic-peach/week-14-the-approved-pull-request-that-stopped-passing-eln</guid>
      <description>&lt;p&gt;&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7746" rel="noopener noreferrer"&gt;#7746&lt;/a&gt;, the OIDC login&lt;br&gt;
initiation, has been approved and waiting since week 10. This week I went back&lt;br&gt;
to actually merge it and found its test suite failing on an error that had&lt;br&gt;
nothing to do with anything I remembered writing.&lt;/p&gt;
&lt;h2&gt;
  
  
  A NameError in code I had already shipped
&lt;/h2&gt;

&lt;p&gt;CI was red across the whole &lt;code&gt;oidc_login&lt;/code&gt; context, ten examples, all the same&lt;br&gt;
failure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NameError:
  uninitialized constant LtiController::LTI_STATE_PURPOSE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That should have been impossible. &lt;code&gt;oidc_login&lt;/code&gt; and &lt;code&gt;lti_state_verifier&lt;/code&gt; both&lt;br&gt;
reference &lt;code&gt;LTI_STATE_PURPOSE&lt;/code&gt;, and the request spec references&lt;br&gt;
&lt;code&gt;LtiController::LTI_STATE_PURPOSE&lt;/code&gt; directly to verify the state it gets back.&lt;br&gt;
If that constant had never existed, this whole context should have failed the&lt;br&gt;
day it was written, back in week 10. It did not. Something had removed a&lt;br&gt;
constant that used to be there.&lt;/p&gt;
&lt;h2&gt;
  
  
  Tracing it
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;git log -p&lt;/code&gt; on the controller file gave a clean timeline. The constants first&lt;br&gt;
appear on 5 August, in the commit that adds &lt;code&gt;oidc_login&lt;/code&gt; itself, with a comment&lt;br&gt;
explaining why the state is signed rather than kept in the session. They survive&lt;br&gt;
the review fixes from week 10. They even survive a rough merge from master on 9&lt;br&gt;
August that briefly left the file unparseable, a splice that dropped the closing&lt;br&gt;
&lt;code&gt;end&lt;/code&gt; of &lt;code&gt;oidc_login&lt;/code&gt; and spliced two of master's filter lines into the method&lt;br&gt;
body. The very next commit repairs that, and its diff still shows the constants&lt;br&gt;
sitting untouched at the top of the class, comment and all.&lt;/p&gt;

&lt;p&gt;The actual disappearance happened somewhere I was not looking. On 21 August, a&lt;br&gt;
different contributor merged a repository wide change to master, adding&lt;br&gt;
&lt;code&gt;after_action :verify_authorized&lt;/code&gt; safety nets across dozens of controllers. On&lt;br&gt;
this controller, on master's copy of the file, that meant inserting&lt;br&gt;
&lt;code&gt;skip_after_action :verify_authorized&lt;/code&gt; as the very first line inside the class&lt;br&gt;
body. Master's copy at that point had no &lt;code&gt;LTI_STATE_PURPOSE&lt;/code&gt; constants, because&lt;br&gt;
none of this branch's work had merged into master yet.&lt;/p&gt;

&lt;p&gt;On 5 September I merged master into my branch again, to catch up before opening&lt;br&gt;
the merge. Both sides had inserted a new line in exactly the same spot, right&lt;br&gt;
after &lt;code&gt;class LtiController &amp;lt; ApplicationController&lt;/code&gt;: master's&lt;br&gt;
&lt;code&gt;skip_after_action&lt;/code&gt;, and my branch's constants with their comment. Diffing the&lt;br&gt;
file immediately before and after that merge shows exactly what happened:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt; class LtiController &amp;lt; ApplicationController
&lt;span class="gd"&gt;-  # The state is signed rather than kept in the session: the launch returns as a
-  # cross-site POST that a SameSite cookie would not survive, and the signature
-  # is what proves the launch answers an initiation we made.
-  LTI_STATE_PURPOSE = "lti.launch.state"
-  LTI_STATE_TTL = 5.minutes
&lt;/span&gt;&lt;span class="gi"&gt;+  skip_after_action :verify_authorized
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No conflict markers survive in the final commit, which is the part that makes&lt;br&gt;
this easy to miss. Two branches touching the same handful of lines usually forces&lt;br&gt;
a manual conflict resolution, and somewhere in resolving that one I kept&lt;br&gt;
master's line and let mine go, without noticing the branch had put something in&lt;br&gt;
that exact spot too. A merge that finishes clean does not look like something&lt;br&gt;
that needs a second look, which is exactly why this one went unnoticed for two&lt;br&gt;
weeks.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Both constants, reinstated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;LTI_STATE_PURPOSE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"lti_oidc_state"&lt;/span&gt;
&lt;span class="no"&gt;LTI_STATE_TTL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;minutes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The purpose string itself does not need to match the original literal, nothing&lt;br&gt;
outside this file ever reads it, only the two internal call sites and the spec&lt;br&gt;
need to agree on the same constant, which they now do by definition. The TTL&lt;br&gt;
does need to stay at five minutes, since the spec travels six minutes forward&lt;br&gt;
and checks the state is gone by then. I confirmed nothing else in the codebase&lt;br&gt;
referenced either constant, ran the project's RuboCop config against the change&lt;br&gt;
to be sure the addition was clean, and pushed. CI went from ten failures back to&lt;br&gt;
green across every job, not just the one that had been failing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this says about "approved and waiting"
&lt;/h2&gt;

&lt;p&gt;An approved pull request sitting unmerged is not a pull request that is done.&lt;br&gt;
It is a pull request whose last green check run is quietly getting further from&lt;br&gt;
the truth every time master moves underneath it, and nothing about the GitHub UI&lt;br&gt;
makes that visible. The checkmark from three weeks ago looks exactly like a&lt;br&gt;
checkmark from this morning.&lt;/p&gt;

&lt;p&gt;This is the same lesson as week 10's development database drift, just at a&lt;br&gt;
different layer. State that nobody actively re-verifies accumulates damage that&lt;br&gt;
stays invisible until something finally runs against it again. There, it was a&lt;br&gt;
migration nobody had rolled back. Here, it was a merge nobody had rerun. Both&lt;br&gt;
times the fix was cheap once found and the finding was the entire cost.&lt;/p&gt;

&lt;p&gt;The practical rule I am taking from this: before merging anything that has sat&lt;br&gt;
approved for more than a couple of weeks, rebase it against current master and&lt;br&gt;
rerun the suite first, rather than trusting a check that ran against a branch&lt;br&gt;
state that no longer exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the project stands
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;#7746&lt;/code&gt; is rebased, fixed, and green again, waiting on nothing but the merge&lt;br&gt;
button. &lt;code&gt;#7764&lt;/code&gt;, the access token exchange, is still open from last week. The&lt;br&gt;
testbench model and runner from two weeks ago are both under review. The test&lt;br&gt;
case editor is still finished and waiting locally.&lt;/p&gt;

&lt;p&gt;Next week: actually watching the login initiation merge, and picking the editor&lt;br&gt;
back up now that it has an access token to eventually authenticate against.&lt;/p&gt;

</description>
      <category>gsoc</category>
      <category>opensource</category>
      <category>lti</category>
      <category>git</category>
    </item>
    <item>
      <title>Week 13: A Kid Only the Platform Can Read</title>
      <dc:creator>Akanksha Trehun</dc:creator>
      <pubDate>Tue, 08 Sep 2026 17:29:36 +0000</pubDate>
      <link>https://dev.to/magic-peach/week-13-a-kid-only-the-platform-can-read-51hj</link>
      <guid>https://dev.to/magic-peach/week-13-a-kid-only-the-platform-can-read-51hj</guid>
      <description>&lt;p&gt;The access token work that jumped the queue last week went up as&lt;br&gt;
&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7764" rel="noopener noreferrer"&gt;#7764&lt;/a&gt;. It took a&lt;br&gt;
security minded review to catch a bug that would have made every single token&lt;br&gt;
request fail, and a second reviewer's confusion, arriving weeks later, to remind&lt;br&gt;
me that a review thread can point at code that no longer exists.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the tool needs before it can call anything
&lt;/h2&gt;

&lt;p&gt;Every LTI Advantage service call, posting a grade, reading a roster, is&lt;br&gt;
authenticated the same way. The tool signs a JWT assertion with its own private&lt;br&gt;
key, POSTs it to the platform's token endpoint under the OAuth 2.0 client&lt;br&gt;
credentials grant, and gets back a bearer token scoped to whatever it asked for.&lt;br&gt;
None of that is CircuitVerse specific. It is 1EdTech's Security Framework&lt;br&gt;
section 5.4.1 built on top of RFC 7523, the JWT profile for client assertions,&lt;br&gt;
which itself sits on RFC 6749's client credentials grant.&lt;/p&gt;

&lt;p&gt;Tokens are cached per deployment and per scope set, refreshed thirty seconds&lt;br&gt;
before they expire rather than exactly at expiry, so a request landing in that&lt;br&gt;
window does not race a token that is technically still valid when read and&lt;br&gt;
expired by the time it reaches the platform. A blank or non string&lt;br&gt;
&lt;code&gt;access_token&lt;/code&gt; in the response is refused instead of cached, and a response with&lt;br&gt;
no &lt;code&gt;expires_in&lt;/code&gt; at all gets no token stored, rather than a guessed default&lt;br&gt;
lifetime. Both of those came from reading the spec's failure modes rather than&lt;br&gt;
just its happy path.&lt;/p&gt;
&lt;h2&gt;
  
  
  The bug a reviewer caught before merge
&lt;/h2&gt;

&lt;p&gt;The first version signed the assertion like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;JWT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;iss: &lt;/span&gt;&lt;span class="n"&gt;deployment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;sub: &lt;/span&gt;&lt;span class="n"&gt;deployment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="n"&gt;signing_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"RS256"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;kid: &lt;/span&gt;&lt;span class="no"&gt;JWT&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;JWK&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signing_key&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;kid&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A reviewer flagged the &lt;code&gt;kid&lt;/code&gt; within a day. &lt;code&gt;Lti::KeyManager&lt;/code&gt;, the class that&lt;br&gt;
publishes CircuitVerse's public key at &lt;code&gt;/lti/jwks&lt;/code&gt;, derives its key id from a&lt;br&gt;
&lt;code&gt;JSON::JWK&lt;/code&gt; thumbprint. &lt;code&gt;JWT::JWK.new(signing_key).kid&lt;/code&gt; computes a &lt;code&gt;kid&lt;/code&gt; using a&lt;br&gt;
different digest entirely. The two never agree, which means the assertion would&lt;br&gt;
have named a key that does not match anything the platform can find at our own&lt;br&gt;
published JWKS endpoint. Every token request would fail, not occasionally, every&lt;br&gt;
time, because the mismatch is structural rather than a bug that only shows up&lt;br&gt;
under some inputs.&lt;/p&gt;

&lt;p&gt;The fix reuses the exact computation &lt;code&gt;KeyManager&lt;/code&gt; already does:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;kid_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signing_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="no"&gt;JSON&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;JWK&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signing_key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;public_key&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;thumbprint&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same input, the public key, same digest, &lt;code&gt;JSON::JWK&lt;/code&gt;'s thumbprint. I checked&lt;br&gt;
this by rereading &lt;code&gt;KeyManager#public_jwk&lt;/code&gt; line by line rather than trusting that&lt;br&gt;
two thumbprint calls with similar names must produce the same output, and they&lt;br&gt;
do:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;public_jwk&lt;/span&gt;
  &lt;span class="n"&gt;jwk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;JSON&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;JWK&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;private_key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;public_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;jwk&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:kid&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||=&lt;/span&gt; &lt;span class="n"&gt;jwk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;thumbprint&lt;/span&gt;
  &lt;span class="n"&gt;jwk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;use: &lt;/span&gt;&lt;span class="s2"&gt;"sig"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;alg: &lt;/span&gt;&lt;span class="s2"&gt;"RS256"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A comment that arrived three weeks late, pointed at code that no longer existed
&lt;/h2&gt;

&lt;p&gt;The fix shipped within a day of the review comment. Three weeks later, a second&lt;br&gt;
reviewer reread the thread from the top and asked why I was "still" using&lt;br&gt;
&lt;code&gt;JWT::JWK.new(signing_key)&lt;/code&gt;, pointing straight at the original code block.&lt;/p&gt;

&lt;p&gt;GitHub pins a review comment to the diff position it was written against. Once&lt;br&gt;
that hunk changes, the thread gets marked "Outdated," but the stale code stays&lt;br&gt;
visible inline right where the comment is anchored, and the label is easy to&lt;br&gt;
miss if you are reading a long thread quickly rather than checking the Files&lt;br&gt;
Changed tab. The question was reasonable. The premise, that the flagged line&lt;br&gt;
was still in the branch, was not.&lt;/p&gt;

&lt;p&gt;The reply pointed at the fix commit by hash, quoted the current &lt;code&gt;kid_for&lt;/code&gt;&lt;br&gt;
method, and reread &lt;code&gt;KeyManager&lt;/code&gt;'s source alongside it to show the two&lt;br&gt;
computations are the same, rather than just asserting "it's fixed, trust me."&lt;br&gt;
That habit, rederiving a claim instead of restating it, is the same one from&lt;br&gt;
week 9's JWT validator work: a test that passes for the wrong reason is worse&lt;br&gt;
than no test, and an explanation that only restates the conclusion is worse than&lt;br&gt;
one that shows the work.&lt;/p&gt;

&lt;h2&gt;
  
  
  A question with a better answer than "I just know"
&lt;/h2&gt;

&lt;p&gt;The same reviewer asked, on a different thread, how I knew which fields&lt;br&gt;
belonged in the token request body at all, and whether it was written down&lt;br&gt;
anywhere or just something I had picked up.&lt;/p&gt;

&lt;p&gt;It is written down, in three places that stack on each other. &lt;code&gt;grant_type&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;scope&lt;/code&gt; come from RFC 6749 section 4.4, the client credentials grant itself.&lt;br&gt;
&lt;code&gt;client_assertion&lt;/code&gt; and &lt;code&gt;client_assertion_type&lt;/code&gt; come from RFC 7523 section 2.2,&lt;br&gt;
the general mechanism for authenticating with a signed JWT instead of a shared&lt;br&gt;
secret. The specific URI value platforms expect for&lt;br&gt;
&lt;code&gt;client_assertion_type&lt;/code&gt;, and the requirement that the JWT's &lt;code&gt;iss&lt;/code&gt; and &lt;code&gt;sub&lt;/code&gt; both&lt;br&gt;
equal the tool's own client id, come from the IMS Security Framework, which is&lt;br&gt;
the layer that actually ties LTI Advantage to that OAuth mechanism.&lt;/p&gt;

&lt;p&gt;I answered with the three documents and the section numbers rather than a&lt;br&gt;
summary of what they say, because a summary is something to remember and a&lt;br&gt;
citation is something to reopen. Anyone auditing this code later, including me&lt;br&gt;
in six months, needs the second thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the project stands
&lt;/h2&gt;

&lt;p&gt;The access token pull request is open with both review threads addressed, the&lt;br&gt;
kid bug fixed and explained, and the spec provenance question answered with&lt;br&gt;
citations rather than recollection. Roster sync and grade passback can now, once&lt;br&gt;
this merges, actually authenticate the calls they have been assuming all along.&lt;/p&gt;

&lt;p&gt;Next week: going back to merge an older, already approved pull request, and&lt;br&gt;
finding out it had stopped passing while nobody was looking at it.&lt;/p&gt;

</description>
      <category>gsoc</category>
      <category>opensource</category>
      <category>oauth</category>
      <category>jwt</category>
    </item>
    <item>
      <title>Week 12: Two Small Pull Requests</title>
      <dc:creator>Akanksha Trehun</dc:creator>
      <pubDate>Tue, 08 Sep 2026 17:26:16 +0000</pubDate>
      <link>https://dev.to/magic-peach/week-12-two-small-pull-requests-1jgg</link>
      <guid>https://dev.to/magic-peach/week-12-two-small-pull-requests-1jgg</guid>
      <description>&lt;p&gt;Both pieces of the autograding groundwork from last week's "pipeline nobody could&lt;br&gt;
feed" post finally went up for review. Getting them out the door taught me more&lt;br&gt;
about the packaging rule I set for myself than the underlying feature did, and&lt;br&gt;
along the way I found a gap in my own merged code that rhymes uncomfortably with&lt;br&gt;
the one I found last week.&lt;/p&gt;
&lt;h2&gt;
  
  
  The rule I made myself follow
&lt;/h2&gt;

&lt;p&gt;Somewhere back in this phase I decided to open small pull requests one at a time&lt;br&gt;
and hold dependent branches locally until the parent merges, instead of stacking&lt;br&gt;
them or folding a dependency into one oversized diff. I have rejected my own&lt;br&gt;
compare pages twice for showing more than one commit, which is a strange thing to&lt;br&gt;
do to yourself but a useful one.&lt;/p&gt;

&lt;p&gt;The test case editor is the clearest reason why. Its branch holds a cherry pick&lt;br&gt;
of the testbench model's commit plus the editor's own commit, because the editor&lt;br&gt;
needs the model's table to exist to run its tests at all. Opening it now would&lt;br&gt;
show two commits on the compare page, and a reviewer would have to work out which&lt;br&gt;
one is actually new. So it stays local until the model merges, at which point I&lt;br&gt;
rebase, the borrowed commit drops out, and what is left is one clean diff over&lt;br&gt;
the editor's own files.&lt;/p&gt;
&lt;h2&gt;
  
  
  Which one first, and why it wasn't obvious
&lt;/h2&gt;

&lt;p&gt;That left a smaller decision: open the testbench model or the runner first.&lt;/p&gt;

&lt;p&gt;The runner's spec is deliberately built so it does not need the model to exist.&lt;br&gt;
It signs its double with &lt;code&gt;Struct.new(:data)&lt;/code&gt; instead of pulling in the real&lt;br&gt;
&lt;code&gt;Testbench&lt;/code&gt; factory, so the runner is green against master on its own. Nothing&lt;br&gt;
about it forces the model to land first.&lt;/p&gt;

&lt;p&gt;I opened the model first anyway, for three reasons that all point the same way.&lt;br&gt;
The test case editor is stacked on the model, not the runner, so unblocking the&lt;br&gt;
editor means merging the model first regardless. The model is also the smaller,&lt;br&gt;
easier diff: one migration, one class, one spec, no external HTTP contract to&lt;br&gt;
explain to a reviewer. And both branches touch &lt;code&gt;db/schema.rb&lt;/code&gt;, which is exactly&lt;br&gt;
the kind of file you do not want two open pull requests editing at once, since&lt;br&gt;
whichever merges second has to rebase past a version bump it did not cause.&lt;/p&gt;
&lt;h2&gt;
  
  
  What actually lives in the Testbench model
&lt;/h2&gt;

&lt;p&gt;One row per assignment, JSON in a &lt;code&gt;jsonb&lt;/code&gt; column, validated in three layers that&lt;br&gt;
mirror the shape of the data itself: suite, then group, then signal.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;data_is_runnable&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"must be a testbench object"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_a?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Hash&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"type must be comb or seq"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="no"&gt;TYPES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;include?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"must define a group"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;empty?&lt;/span&gt;

  &lt;span class="n"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;validate_group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each group needs a case count and at least one input and one output. Each signal&lt;br&gt;
needs a label to bind to a circuit pin, a positive bit width, and exactly as many&lt;br&gt;
values as its group's case count. That last check is why validation lives in&lt;br&gt;
Ruby instead of a database constraint. A &lt;code&gt;jsonb&lt;/code&gt; column schema check can enforce&lt;br&gt;
shape, but it cannot easily say "this array's length must equal that other&lt;br&gt;
field's value," and that cross-field relationship is most of what makes a&lt;br&gt;
testbench actually runnable rather than merely well formed.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;assignment_id&lt;/code&gt; is unique at both levels, a database index and a model&lt;br&gt;
validation, because a race on create could otherwise slip a second row past a&lt;br&gt;
Ruby only check. &lt;code&gt;TYPES&lt;/code&gt; is closed to &lt;code&gt;comb&lt;/code&gt; and &lt;code&gt;seq&lt;/code&gt;, the two suite types the&lt;br&gt;
simulator can actually execute, so a suite claiming &lt;code&gt;verilog&lt;/code&gt; fails at save time&lt;br&gt;
instead of failing later, mid grade, in front of a student.&lt;/p&gt;
&lt;h2&gt;
  
  
  The runner, and the decision that already happened
&lt;/h2&gt;

&lt;p&gt;Most of the runner's design was decided last week, before a line of it existed.&lt;br&gt;
This week was writing it down.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;:,&lt;/span&gt; &lt;span class="n"&gt;testbench&lt;/span&gt;&lt;span class="p"&gt;:)&lt;/span&gt;
  &lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;testbench&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One entrypoint. It posts the project's circuit JSON and the testbench's suite&lt;br&gt;
JSON to a sidecar service over HTTP, with timeouts split into connect, write,&lt;br&gt;
and read rather than one blanket number, because a hung connection should fail&lt;br&gt;
fast while an actual simulation run legitimately needs more time than the&lt;br&gt;
handshake. Every way this can go wrong, a bad status, an unreachable host, a&lt;br&gt;
body that is not JSON, a response with no groups, collapses into one&lt;br&gt;
&lt;code&gt;Autograder::Runner::RunnerError&lt;/code&gt;, so the grading job that calls this later only&lt;br&gt;
ever needs to rescue one thing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;Result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Struct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:passed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:groups&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;score&lt;/span&gt;
    &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;zero?&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;passed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fdiv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The raw &lt;code&gt;groups&lt;/code&gt; array rides along inside the result even though only &lt;code&gt;score&lt;/code&gt;&lt;br&gt;
matters right now, because the results page a few pull requests from now needs&lt;br&gt;
to show which specific case failed, not just the fraction. The endpoint's host&lt;br&gt;
comes from &lt;code&gt;SIMULATOR_RUNNER_URL&lt;/code&gt;, following the same undocumented environment&lt;br&gt;
variable pattern the codebase already uses for &lt;code&gt;YOSYS_PATH&lt;/code&gt;, so anyone who has&lt;br&gt;
configured one external tool location here already knows how to configure this&lt;br&gt;
one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "next" turned out to mean
&lt;/h2&gt;

&lt;p&gt;Last week's post ended by promising the editor would land next. It has not, and&lt;br&gt;
writing the runner's pull request description is what changed my mind.&lt;/p&gt;

&lt;p&gt;I went back to reread the roster sync pull request from two weeks ago, the one&lt;br&gt;
that calls the platform's Names and Roles service, to check whether it needed&lt;br&gt;
anything from this new access token machinery. It does. So does grade passback.&lt;br&gt;
Both of those already assume an &lt;code&gt;Authorization: Bearer&lt;/code&gt; header carrying a token&lt;br&gt;
scoped to the right service, and until this week nothing in the codebase could&lt;br&gt;
produce one. I had built two pull requests that read from and write to platform&lt;br&gt;
services that require an OAuth exchange neither of them performs.&lt;/p&gt;

&lt;p&gt;This is the same shape of gap as last week's missing test case editor: a feature&lt;br&gt;
description satisfied on paper by code that quietly assumes a piece nobody has&lt;br&gt;
built yet. The difference is I caught this one myself, by rereading my own&lt;br&gt;
merged work with the new piece in mind, rather than having a mentor's evaluation&lt;br&gt;
catch it for me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the project stands
&lt;/h2&gt;

&lt;p&gt;The testbench model and the runner are both open for review, in that order.&lt;br&gt;
Roster sync and grade passback are merged but cannot actually authenticate&lt;br&gt;
against a platform yet. The test case editor is finished and waiting locally for&lt;br&gt;
the model to merge.&lt;/p&gt;

&lt;p&gt;Next week: the access token exchange that both of those merged pull requests&lt;br&gt;
have been silently depending on.&lt;/p&gt;

</description>
      <category>gsoc</category>
      <category>lti</category>
      <category>opensource</category>
      <category>dev</category>
    </item>
    <item>
      <title>Week 11: Welcome to my Midterm evaluation</title>
      <dc:creator>Akanksha Trehun</dc:creator>
      <pubDate>Sun, 16 Aug 2026 06:06:06 +0000</pubDate>
      <link>https://dev.to/magic-peach/week-11-welcome-to-my-midterm-evaluation-2l</link>
      <guid>https://dev.to/magic-peach/week-11-welcome-to-my-midterm-evaluation-2l</guid>
      <description>&lt;p&gt;Midterm evaluations ran from the 10th to the 14th. Deep linking and roster sync both&lt;br&gt;
shipped, two more pull requests merged, and then I sat down to write an honest account of&lt;br&gt;
where the project stands — which is how I found out that the plan I have been working&lt;br&gt;
from for a month builds a machine nobody can switch on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deep linking, end to end
&lt;/h2&gt;

&lt;p&gt;Two pull requests, two days apart.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7772" rel="noopener noreferrer"&gt;#7772&lt;/a&gt; handles the request&lt;br&gt;
side: a launch arrives with &lt;code&gt;LtiDeepLinkingRequest&lt;/code&gt; as its message type instead of&lt;br&gt;
&lt;code&gt;LtiResourceLinkRequest&lt;/code&gt;, and everything downstream has to route differently. The&lt;br&gt;
platform sends along &lt;code&gt;deep_linking_settings&lt;/code&gt; — where to return the response, whether&lt;br&gt;
multiple items are allowed, whether the platform wants a title back — and that has to be&lt;br&gt;
stashed for the response, which happens on a completely separate request.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7777" rel="noopener noreferrer"&gt;#7777&lt;/a&gt; is what the instructor&lt;br&gt;
actually sees: a picker listing their circuits and assignments, with the selection posted&lt;br&gt;
back to the LMS as a signed JWT.&lt;/p&gt;

&lt;p&gt;The satisfying part is that neither pull request needed anything new from the protocol&lt;br&gt;
layer. The JWT signing is the key manager from week 4. The validation on the way in is&lt;br&gt;
the validator from week 9. Deep linking is a different &lt;em&gt;conversation&lt;/em&gt; with the platform,&lt;br&gt;
but it is the same handshake underneath, and by this point the handshake is just there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The roster
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7779" rel="noopener noreferrer"&gt;#7779&lt;/a&gt; reads a course roster&lt;br&gt;
over the Names and Roles service — pagination via &lt;code&gt;Link&lt;/code&gt; headers, roles parsed out of the&lt;br&gt;
IMS role URIs. &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7781" rel="noopener noreferrer"&gt;#7781&lt;/a&gt; turns&lt;br&gt;
that into group members.&lt;/p&gt;

&lt;p&gt;The interesting decision in the second one is how users get matched. Not on email — that&lt;br&gt;
was week 5's lesson, and the reason for it holds here too: a platform's privacy settings&lt;br&gt;
may mean no email arrives at all, and even when one does, it might belong to a different&lt;br&gt;
CircuitVerse account than the person clicking. The match is on &lt;code&gt;sub&lt;/code&gt; plus issuer, the&lt;br&gt;
same identity pair the launch signs the user in with. A roster import that mapped on&lt;br&gt;
email could silently add a stranger to a course.&lt;/p&gt;

&lt;p&gt;Members created this way get marked as LTI-synced, which matters for the drop-handling&lt;br&gt;
pull request that follows: when someone disappears from the LMS roster, only synced&lt;br&gt;
members get deactivated. Somebody who joined the CircuitVerse group directly should not&lt;br&gt;
be removed because they were never in Canvas to begin with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two merges
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7715" rel="noopener noreferrer"&gt;#7715&lt;/a&gt;, the JWT validator,&lt;br&gt;
merged on the 11th. &lt;a href="https://github.com/CircuitVerse/CircuitVerse/pull/7763" rel="noopener noreferrer"&gt;#7763&lt;/a&gt;,&lt;br&gt;
the resource link model, merged on the 12th. Both had been sitting approved for a while —&lt;br&gt;
the validator since the end of July.&lt;/p&gt;

&lt;p&gt;Five merged now: the 1.1 passback fix, the deployment model, the JWKS and tool&lt;br&gt;
configuration endpoints, the validator, and the resource link. Eight more open and&lt;br&gt;
waiting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The evaluation
&lt;/h2&gt;

&lt;p&gt;What the evaluation actually asked me to do was describe the state of the project, which&lt;br&gt;
sounds trivial and was not. Writing "here is what works" forces a different question than&lt;br&gt;
"here is what I built this week," and the two answers were not the same shape.&lt;/p&gt;

&lt;p&gt;So I went back to the three issues this project exists to close and read them line by&lt;br&gt;
line against what is in the repository. Not the proposal — the issues, as filed.&lt;/p&gt;

&lt;h2&gt;
  
  
  A pipeline nobody could feed
&lt;/h2&gt;

&lt;p&gt;The autograding phase of my plan had six pull requests: a test case model, a headless&lt;br&gt;
runner wrapper, a grading result, a job that runs on submission, a results page, and a&lt;br&gt;
push to the LMS gradebook. Student submits, autograder runs, grade lands in Canvas.&lt;/p&gt;

&lt;p&gt;Every one of those is about &lt;em&gt;executing&lt;/em&gt; test cases. Not one of them is about &lt;em&gt;creating&lt;/em&gt;&lt;br&gt;
test cases.&lt;/p&gt;

&lt;p&gt;The issue says it plainly — "a test case editor in the assignment settings UI where&lt;br&gt;
instructors can add, edit and delete test cases" — and I had somehow read that as&lt;br&gt;
satisfied by a database table. It is not. A model with no UI means the only way to define&lt;br&gt;
a test suite is the Rails console, which is not a feature an instructor can use. I had&lt;br&gt;
planned six pull requests that would ship a grading pipeline with no way to put anything&lt;br&gt;
into it.&lt;/p&gt;

&lt;p&gt;Reading further with that lens, four more gaps fell out. There was no visible/hidden flag&lt;br&gt;
on test cases, which both issues ask for. There was nothing for the autograde&lt;br&gt;
configuration — partial credit, allowed attempts, whether students see the cases before&lt;br&gt;
submitting. "Versioned with the assignment" had no plan behind it at all, and my model&lt;br&gt;
actively worked against it: one row per assignment, edited in place, so changing a suite&lt;br&gt;
would quietly invalidate every grade already produced from it.&lt;/p&gt;

&lt;p&gt;And the least visible one, which I only found by reading the &lt;code&gt;Grade&lt;/code&gt; model rather than&lt;br&gt;
remembering it: nothing in the plan mapped a score onto an assignment's grading scale.&lt;br&gt;
The autograder produces a fraction. &lt;code&gt;Grade&lt;/code&gt; validates against &lt;code&gt;letter&lt;/code&gt;, &lt;code&gt;percent&lt;/code&gt;, or&lt;br&gt;
&lt;code&gt;custom&lt;/code&gt;, and rejects &lt;code&gt;no_scale&lt;/code&gt; outright. Autograding an assignment with no grading&lt;br&gt;
scale would have failed at the last step, after the run, with the student watching.&lt;/p&gt;

&lt;h2&gt;
  
  
  Twenty-four becomes twenty-seven
&lt;/h2&gt;

&lt;p&gt;Week 7's post was called "Twenty-Four Pull Requests." That plan is now twenty-seven, all&lt;br&gt;
three additions in the autograding phase: the test case editor, the autograde settings,&lt;br&gt;
and the grading-scale mapping — plus reworking the grading result to carry an attempt&lt;br&gt;
number and a snapshot, and folding hidden-case redaction into the results page.&lt;/p&gt;

&lt;p&gt;The snapshot is how "versioned with the assignment" gets solved, and it is the change I&lt;br&gt;
am happiest with. Rather than a version table with all the machinery that implies, each&lt;br&gt;
grading result stores a copy of the suite it ran against. Edit the suite afterwards and&lt;br&gt;
old results stay reproducible, because the thing that produced them is still sitting&lt;br&gt;
right there next to them.&lt;/p&gt;

&lt;p&gt;Three of the nine are written. The model and the runner are pushed; the editor is done&lt;br&gt;
and waiting locally, because it needs the model's table to exist and I would rather open&lt;br&gt;
one clean pull request after the first merges than one showing two commits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reusing a format instead of inventing one
&lt;/h2&gt;

&lt;p&gt;The nicest thing I wrote this week is four words long.&lt;/p&gt;

&lt;p&gt;A test case needs a name, input pin values, expected output pin values, and a hidden&lt;br&gt;
flag. The simulator's testbench format already has &lt;em&gt;groups&lt;/em&gt; — a named collection of cases&lt;br&gt;
with input and output signals. My first instinct was to add fields: a &lt;code&gt;name&lt;/code&gt; array, a&lt;br&gt;
&lt;code&gt;hidden&lt;/code&gt; array, indices lining up across them.&lt;/p&gt;

&lt;p&gt;Then: a test case is just a group with &lt;code&gt;n: 1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One case, one name, one hidden boolean. It validates against the model I had already&lt;br&gt;
written without changing a line of it, it invents no schema, and when the suite reaches&lt;br&gt;
the simulator it is a format the simulator already knows how to run. The editor&lt;br&gt;
serialises its table into exactly that on submit.&lt;/p&gt;

&lt;p&gt;I have written the other version of this before — the one with parallel arrays and a&lt;br&gt;
comment explaining how the indices correspond. It works right up until someone deletes a&lt;br&gt;
row from one array and not the others.&lt;/p&gt;

&lt;h2&gt;
  
  
  Not reimplementing the simulator
&lt;/h2&gt;

&lt;p&gt;The runner wrapper was the piece I was most nervous about, and the design question was&lt;br&gt;
where the simulation actually happens.&lt;/p&gt;

&lt;p&gt;The tempting answer was Ruby: run the circuit server-side, in the language the rest of&lt;br&gt;
the grading code is written in. I talked myself out of it in about ten minutes. It means&lt;br&gt;
reimplementing thousands of lines of simulation logic, and then maintaining two engines&lt;br&gt;
that will drift — and the failure mode of that drift is a student's circuit passing in&lt;br&gt;
the browser and failing at grading. For a grading tool there is no worse bug.&lt;/p&gt;

&lt;p&gt;Driving a real browser was the next candidate, and &lt;code&gt;capybara-playwright-driver&lt;/code&gt; is&lt;br&gt;
already in the Gemfile, so it looked nearly free. A browser process per submission is not&lt;br&gt;
nearly free.&lt;/p&gt;

&lt;p&gt;Shelling out to Node was the closest call, because there is precedent: the Yosys&lt;br&gt;
integration in this repository does exactly that. I went with an HTTP call to a sidecar&lt;br&gt;
service instead, mirroring the other half of that same integration — a URL from the&lt;br&gt;
environment, the same timeout-and-post shape the simulator controller already uses. It&lt;br&gt;
keeps the engine on the frontend repository's release cadence, where that code and the&lt;br&gt;
people who know it live.&lt;/p&gt;

&lt;p&gt;Every failure collapses into one error class: bad circuit data, a non-2xx, a timeout, a&lt;br&gt;
non-JSON body, an empty result. That is deliberate. The only caller is a background job,&lt;br&gt;
and there is exactly one right answer to all of them — mark the run failed, leave the&lt;br&gt;
student's grade alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the project stands
&lt;/h2&gt;

&lt;p&gt;Five merged, eight open, three written and waiting. The protocol layer is done and&lt;br&gt;
merged. Grade passback, deep linking, and roster sync are all in the queue. Autograding is&lt;br&gt;
started.&lt;/p&gt;

&lt;p&gt;The plan grew by three pull requests this week, which on a burndown chart is a bad week.&lt;br&gt;
It is the most useful thing I did. I have spent eleven weeks getting good at making&lt;br&gt;
mechanisms work, and the evaluation caught me having built most of a system that no&lt;br&gt;
instructor could have used, because I kept reading a feature request as a description of&lt;br&gt;
a machine instead of a description of a person trying to get something done.&lt;/p&gt;

&lt;p&gt;Next week: the editor lands, and then the settings and the grading result behind it.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>opensource</category>
      <category>career</category>
      <category>learning</category>
    </item>
  </channel>
</rss>
