GradUP is a web training platform (LMS): a course catalog, learning paths, content with video embedded from outside, assessments, and a certificate. It is a modular monolith in Java 21 and Spring Boot 3, with a Thymeleaf interface (htmx and Alpine), PostgreSQL, and Flyway migrations. The artifact name in the packages remains com.portal / portal-treinamentos; the public product is GradUP.
This technical case shows how the exam is drawn per attempt, what “do not repeat” actually covers, and what is still missing. The numbers were checked in the repository on the October 29, 2026 version (commit f349c11). The Maven suite was not rerun for this write-up — the author’s decision for this effort; the results cited come from the July 10, 2026 QA report versioned in the project and from reading the code.
Transparency: GradUP was designed and is developed by Jeremias Cardoso, the author of this blog. The public status is “In implementation”. The text uses the tone of training evidence (including record-keeping requirements in the spirit of the NRs) only where the code and the ADRs support the claim. Do not confuse this LMS with the Empresa GradUP repository, which is a different project (orchestration).
Update note (November 2, 2026): this post was rewritten as a technical case. The September 19, 2026 version (adjusted on October 23) described the exam engine in general terms and still left “no repetition” ambiguous. This version separates what holds inside one attempt from what holds across attempts, with numbers and limits checked in the repository.
The problem: a fixed exam and an answer key that circulates
In corporate training and in training tied to occupational safety standards, the exam needs to serve as evidence: who took it, when, with which cutoff, and with which questions. If the exam is always the same block in the same order, the learner memorizes the answer key on the second attempt, and two colleagues compare answers. The evidentiary value drops.
Until the July 8, 2026 epic, in GradUP a course exam was the entire bank, in fixed position order, with a constant 70% cutoff in the code. ADR-0010 recorded the change: a bank larger than what is administered, a difficulty mix, a draw per attempt, a persisted set, and a cutoff frozen at exam time — explicit drivers of integrity, tamper resistance, and maintainability.
The system in numbers
Local measurement on November 2, 2026, on commit f349c11.
| Measure | Value |
|---|---|
Java in src/main | 437 files; about 31.8 thousand physical lines (~27.9 thousand non-empty) |
Java in src/test | 199 files; about 33.3 thousand physical lines |
@Test / @ParameterizedTest annotations | 1,595 (static count at HEAD; suite not rerun for this write-up) |
ExamSortitionTest | 16 tests in the domain file |
Templates/SQL in resources | 145 files; about 14.1 thousand lines |
| Git history | 163 commits |
QA on July 10, 2026 (commit 63ad21f, report in the repo) | mvn test: 722 run, 0 fail, 105 skip; dynamic battery for the scope: 26/26 PASS |
Modular monolith: what stays on the server
GradUP is built for self-hosting or a managed database (Neon appears in the guides). Video does not pass through the application server: an external embed (YouTube/Vimeo), the decision in ADR-0004, so streaming is not paid for in the same process as the LMS. The UI is rendered on the server; there is no decoupled SPA as the core. That reduces front-end complexity and pushes the business rule — including the exam rule — into testable Java.
Catalog, company, IAM, billing, assessment, and reporting modules live in the same deploy. The learner’s exam goes through QuizService and the ExamSortition domain. Per-course configuration lives in quiz_settings (how many questions to administer, the BAIXO/MÉDIO/ALTO mix, the cutoff percentage). The drawn set and the answers go to quiz_attempt_questions; the cutoff used on that attempt lives in quiz_attempts.pass_percent.
The instructor imports a bank from Excel, reviews near-duplicates, and configures the exam; the learner takes the attempt; the company and analytics read the already persisted passed without recalculating the old cutoff. That separation explains why changing the percentage later does not reopen verdicts: the fact of the attempt has already been recorded.
The ExamSortition engine
ExamSortition is a pure function: it receives the bank, the configuration, and a RandomGenerator, and returns the subset. No Spring and no I/O. In production the source is SecureRandom; in tests, a fixed seed.
Rules the code documents and runs:
- Administer all (null count or ≥ bank size): returns the bank in
positionorder, without shuffling — the previous behavior preserved. - Largest-remainder quotas over the number administered and the percentage mix; the sum of the quotas is always N.
- A shortfall in one band → a neighboring band fills the deficit; it does not block the exam.
- No replacement of a question in the same attempt.
- No repeated stem in the same attempt: a normalized key (no accent, no letter case, collapsed spaces). If, after excluding near-duplicates, there are not enough unique stems, the exam comes out smaller than requested — an honest fallback.
- The final set is shuffled so difficulty does not leak through the order.
The file has 258 lines. The 16 tests in ExamSortitionTest cover quotas, a duplicate stem by accent/case, and the case in which the exam shrinks instead of repeating a question.
What “do not repeat” means — and what it does not mean
This is the distinction the old version of the post left ambiguous.
Inside the same attempt
- The same question (same id) does not appear twice.
- Two ids with the same normalized stem also do not: the guard treats them as the same question.
- The choices on the learner’s screen come out in the registered order (
optionA…optionEin the template); what gets shuffled is the set of questions, not the letters of each item.
Across attempts
- The draw does not exclude the previous attempt’s history. A question (or a stem) that landed on attempt 1 can land again on attempt 2.
- What changes is the composition drawn from the bank and the mix, not a guarantee of an unseen exam.
- Saying “it reduces the chance of repetition” is honest; saying “no repetition on every attempt” would be false marketing.
The July 10 QA measured exactly the “inside the attempt” side: draws A and B with three questions, distinct normalized keys, and the deliberate pair “capital of Brazil” with different case/accent never appeared twice on the same exam. That does not prove exclusion across attempts — and the product does not promise that in the domain.
Session, attempt, and the risk of rerolling
The set is born on the exam GET, lives in the HTTP session, and becomes a persisted attempt only on SUBMIT. Leaving the screen does not consume an attempt. F5 on the GET keeps the set. A POST without a set in the session redraws without grading and without consuming an attempt.
ADR-0010 records an accepted residual risk: if the session dies before submit (expiration, restart, another browser), the next GET draws a new set at no attempt cost. A patient learner can reroll until they like the set. The alternative (creating the attempt on GET) would break the semantics of “N attempts”. The mitigation trigger in the ADR is observed abuse → persist the pending set outside the session.
A single instance is a premise (ADR-0006). Multiple instances would require a shared session or another bridge.
Certificate, frozen cutoff, and the NR
A configurable cutoff is not retroactive: old attempts keep the passed of their time. The certificate prints the score and the cutoff of the same anchor attempt (preference for the best passing one). If the certificate is issued by completion and the learner has only failures, the R4 product decision (July 8, 2026) is to omit the score on the PDF — not to print a score below the cutoff on an official document.
The per-question data already exists in quiz_attempt_questions (set, answer, frozen correct/incorrect). The drill-down UI for the auditor was still recorded as a fast-follow in the points of attention: evidence in the database ≠ a ready screen.
For training in the spirit of the NRs, what the code supports today is: an exam drawn from a larger bank, cutoff and verdict frozen per attempt, and a certificate consistent with the anchor. What it does not support: claiming automatic regulatory compliance, covering an in-person practical portion, or guaranteeing that the auditor already has the full per-attempt inspection screen.
Other honest limits
- AI question generation (when enabled): multiple choice A–D only, default grade MÉDIO; the manual form and Excel already accept a richer model — a documented gap.
- Cap of 99 questions per course (ADR-0009).
- Rate limit on signup/login: 5 attempts / 60 s per IP (filter tests).
- Billing and Asaas exist in the code; this case does not go deep on billing and does not claim paying customers.
- Status: In implementation. No claim of a fleet of companies in production in this text.
Incidents and decisions that became rules
- Exam = entire bank → ADR-0010 and
ExamSortition(July 8, 2026). - Near-duplicate by accent → stem guard + a smaller exam if needed; “Possible duplicate” badge on import.
- Reroll at no cost if the session dies → accepted risk with a mitigation trigger.
- Non-retroactive cutoff and omit the score when there is only a failure on issuance by completion.
- Audit data before the UI — persist the NR fact even without the auditor’s screen in the same release.
Tests (a reading, with no rerun on this effort)
At HEAD f349c11 there are 1,595 test annotations. The July 10, 2026 report, at commit 63ad21f, recorded mvn test with 722 run / 0 fail / 105 skip and a dynamic battery of 26/26 in the scope of that QA (draw, QR invite, company certificate, and so on). This write-up did not repeat the suite: it uses the versioned report and a static reading of the code. Anyone republishing with an execution number for “today” needs to run it again and update the extract. The same logic as gates that prove applies here: a number without evidence you can point to does not close the task.
What this case does not prove
- That GradUP is approved for any specific NR, or that it replaces the in-person practical portion required by several risk standards. The code supports an exam record and a certificate consistent with the attempt; the in-person application remains with the training provider.
- That “an unseen exam on every attempt” is a product rule — it is not. Across attempts, questions and stems can come back.
- That the per-attempt audit UI is already complete for the inspector or the external auditor. The data is there; the drill-down screen was a fast-follow in the July record.
- That the suite with 1,595
@Testannotations is green at this moment on the author’s machine. This write-up did not rerun it. - Business results (active learners, revenue, trial conversion, paying companies).
- Full parity between AI generation and manual question entry: the generator is still born poorer than the form and Excel.
GradUP sits, in the author’s portfolio, alongside operations systems such as SIGCOT and the switching simulator. The common thread is not “one more LMS”: it is recording what happened in a way that can be audited later — an intervention request (SI) and a switching operation on one side, an exam attempt on the other.
Lessons
- Separate “on the same exam” from “on the next attempt”. Mixing the two becomes a public contradiction.
- An honest fallback (a smaller exam) is better than a repeated question.
- Audit data can be born before the screen. The NR fact in the database does not wait for the wireframe.
- A residual risk written in the ADR avoids a surprise when the learner rerolls the session.
- An old QA report is historical evidence, not a substitute for a new run — state when you did not run it again.
- Package name ≠ product name.
com.portalon the artifact and “GradUP” on the brand can coexist; what cannot happen is mixing this LMS with the Empresa GradUP repository.
Sources
- Code, ADRs, points of attention, QA report, and history: Gradup repository (
Portal-Treinamentos) at commitf349c11(October 29, 2026), read by the author; extract with excerpts, the counting method, and the limits, verified on 2026-11-02. Maven suite not rerun for this write-up. - ADR-0010 — Exam drawn per attempt (July 8, 2026), in the product repository.
- Gradup QA report of July 10, 2026 (
docs/qa-reports/qa-2026-07-10-1149.md), commit63ad21f.



