Repository navigation
feat(certifications): Wave D atestats — verifiable certificates + open-source decision - #309
Conversation
…le certificates + open-source decision
Closes the two remaining Wave D roadmap items:
1. certifications/attestations (the honest variant — 'a leaderboard-derived
atestat'; also H-120 verifiable participation certificates):
- New backend app: Certificate model (uuid verify id, WC-YYYY-NNNNNN
serial, frozen bilingual achievement snapshot, revocation ledger) with
three derivations — CONTEST_PARTICIPATION (official rated finisher,
rank + post-rating), RATING_MILESTONE (1200..2000, peak semantics),
SOLVE_MILESTONE (10..500, same derivation as the leaderboard).
- Issuance hooks: the ratings commit (inside compute_contest_ratings)
and the post-AC leaderboard recompute; rollback_contest_ratings now
revokes the discarded contest's atestats and audits milestones the
replayed ladder no longer supports. Deliberately NO create endpoint.
- API: anonymous GET verify/<uuid>/, GET mine/ (paginated), public
GET ?username= (LEG-2: minors/inactive excluded), admin POST revoke
(reason mandatory, first reason sticks).
- manage.py issue_certificates: idempotent backfill (--notify opt-in).
- Frontend: /certificates holder list + /certificates/[uuid] anonymous
verification page with printable atestat sheet (UXP-3 print CSS);
navbar account link; certificate_issued notification rendering +
deep link; 26 keys hand-translated across all 28 locale catalogs
(+nav_certificates), public copies re-exported.
- Tests: 30 backend tests (100% module coverage; suite total 100%),
12 vitest, 5 e2e (mock api) incl. honest REVOKED + not-found paths.
2. Open-source/self-host decision — docs/DECISION-OPEN-SOURCE.md: closed
platform pre-traction (maintenance + security-audit cost), rolling
component-level OSS (K-149), content portability as the community
promise; explicit reopen path recorded.
Also updates the trackers (BACKLOG.md, ROADMAP.md layers 0/4/5/6/8),
API-MAP and RUNBOOK (Wave D operator notes).
risk:low
Codex Review Summary: ✅ CompletedThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
| for row in UserRating.objects.values("user_id", "rating") | ||
| } | ||
| stale = Certificate.objects.filter( | ||
| kind=Certificate.Kind.RATING_MILESTONE, revoked_at__isnull=True | ||
| ) | ||
| for cert in stale: | ||
| rating = ratings.get(cert.user_id) | ||
| if rating is None or (cert.threshold or 0) > rating: | ||
| _revoke(cert, reason) | ||
| revoked += 1 | ||
| return revoked |
There was a problem hiding this comment.
Bug: audit_rating_milestones compares thresholds against current UserRating.rating rather than peak rating, revoking certificates from users who experienced normal subsequent rating drops.
Severity: HIGH
Suggested Fix
Derive each user's peak historical rating from replayed RatingHistory records (e.g., using Max('rating')) instead of querying current UserRating.rating, and evaluate milestone revocation against that peak value.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.
Location: backend/certificates/services.py#L194-L217
Potential issue: `audit_rating_milestones` evaluates users against their current
`UserRating.rating` rather than their historical peak rating. When
`rollback_contest_ratings` is executed, ratings history is replayed and
`audit_rating_milestones` is called platform-wide. If a user previously achieved a
rating milestone but later dropped organically below that threshold in subsequent
contests, their valid milestone certificate is erroneously revoked. Because `_issue`
looks up existing records and skips re-issuance, revoked certificates cannot be
re-earned even if the rating increases again.
Did we get this right? 👍 / 👎 to inform future reviews.
| from certificates.services import recompute_solve_milestones | ||
|
|
||
| for user_id, acc in per_user.items(): | ||
| if acc["solved_count"] >= 10: | ||
| recompute_solve_milestones(user_id) |
There was a problem hiding this comment.
Bug: recompute_all_leaderboards invokes recompute_solve_milestones using the default notify_user=True, causing scheduled background recomputations to dispatch user notifications.
Severity: MEDIUM
Suggested Fix
Pass notify_user=False when calling recompute_solve_milestones within the loop in recompute_all_leaderboards.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.
Location: backend/leaderboard/aggregation.py#L157-L161
Potential issue: `recompute_all_leaderboards` in `backend/leaderboard/aggregation.py`
invokes `recompute_solve_milestones(user_id)` without specifying `notify_user=False`. As
a result, the default `notify_user=True` triggers `notify()` via `_issue()` for newly
generated certificates during background processing. When the scheduled nightly Celery
beat task runs, it sends non-event-driven notification messages to users rather than
keeping replay and backfill tasks silent.
Did we get this right? 👍 / 👎 to inform future reviews.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c6c2e45c75
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| ratings = { | ||
| row["user_id"]: row["rating"] | ||
| for row in UserRating.objects.values("user_id", "rating") | ||
| } |
There was a problem hiding this comment.
Audit rating milestones against the surviving historical peak
When a user legitimately reached 1800 and later fell to 1450, rolling back an unrelated contest revokes their 1600 and 1800 certificates even if the surviving, replayed history still contains the qualifying result. This audit reads the current UserRating rather than the maximum surviving RatingHistory.rating, contradicting the peak-achievement semantics. Because _issue never restores revoked certificates, this incorrectly invalidates those achievements permanently. Compare each threshold against the surviving historical maximum instead.
Useful? React with 👍 / 👎.
| for row in UserRating.objects.values("user_id", "rating"): | ||
| milestones += len( | ||
| issue_rating_milestones(row["user_id"], row["rating"], notify_user=notify_users) |
There was a problem hiding this comment.
Backfill milestones from historical ratings rather than current state
For existing users whose ratings have fallen since their peak, the initial issue_certificates backfill omits milestones they previously earned. For example, a user with an 1800 history entry and a current rating of 1450 receives only the 1200 and 1400 certificates, whereas a user following the same trajectory after deployment retains all four milestones. Derive the backfilled rating milestones from each user's maximum RatingHistory.rating so rollout and recovery reproduce the event-driven issuance semantics.
Useful? React with 👍 / 👎.
| certificate = get_object_or_404( | ||
| Certificate.objects.select_related("user"), uuid=certificate_uuid | ||
| ) | ||
| return Response(CertificateSerializer(certificate).data) |
There was a problem hiding this comment.
Revoke participation certificates when their holder is disqualified
If an organizer disqualifies a participant after certificates have been issued, this verification endpoint continues returning VALID for that participant's certificate. The existing ContestViewSet.disqualify action in backend/contests/views.py only updates the participation flags; the new certificate code has no disqualification hook, and revoke_contest_certificates is called only by the separate whole-contest ratings rollback. Consequently, someone removed from the official standings can still present a valid final-rank certificate. Wire the disqualification action to revoke the affected participant's certificate with the recorded reason.
Useful? React with 👍 / 👎.
| <div style={{ display: 'flex', gap: '1rem', alignItems: 'center', marginTop: '1.25rem' }} data-noprint> | ||
| <PrintCertificateButton label={t('certificates_print_button', 'Print / save as PDF')} /> | ||
| <p style={{ fontSize: '0.8rem', color: 'var(--muted)' }}> | ||
| {t('certificates_verify_note', 'Anyone can verify this certificate at')}{' '} | ||
| <code style={{ fontFamily: 'monospace' }}>{verifyUrl}</code> |
There was a problem hiding this comment.
Keep the verification URL visible on printed certificates
When the holder prints or saves the certificate as a PDF, src/app/globals.css hides this entire data-noprint container, including the only displayed verification URL and UUID. The remaining certificate contains a serial number, but backend/certificates/urls.py provides verification only by UUID, not by serial. A recipient of the exported certificate therefore has no verification identifier unless browser-generated print headers happen to include the page URL. Hide only the print button and retain an explicit verification URL on the printable sheet.
Useful? React with 👍 / 👎.
| cert, created = Certificate.objects.get_or_create( | ||
| user_id=user_id, | ||
| kind=kind, | ||
| contest=contest, | ||
| threshold=threshold, | ||
| defaults={"details": details or {}}, | ||
| ) | ||
| if not created: | ||
| return None | ||
| cert.serial = f"WC-{timezone.now().year}-{cert.pk:06d}" | ||
| cert.save(update_fields=["serial"]) |
There was a problem hiding this comment.
Bug: Milestone certificates revoked during rating rollbacks cannot be re-issued or un-revoked when a user re-earns the milestone threshold.
Severity: LOW
Suggested Fix
Distinguish between administrative revocations and audit-driven revocations (e.g., via a revocation reason or source field). Allow _issue or a dedicated re-validation path to reactivate or update audit-revoked milestone certificates when a user legitimately re-qualifies for the threshold.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.
Location: backend/certificates/services.py#L59-L71
Potential issue: In `_issue`, `Certificate.objects.get_or_create` returns
`created=False` if a milestone certificate record already exists, returning `None`
without un-revoking or updating it. When rating history is rolled back and replayed via
`rollback_contest_ratings`, `audit_rating_milestones` revokes milestones for users who
temporarily drop below the threshold. If a user subsequently climbs back and re-earns
the milestone rating legitimately in a future contest, `_issue` skips issuance because
the revoked row already exists. Due to the `uniq_certificate_threshold` database
constraint, a new certificate cannot be created, leaving the milestone permanently
revoked.
| # milestones for every finisher (idempotent; notifications follow the | ||
| # caller's replay-suppression choice). | ||
| from certificates.services import ( | ||
| issue_contest_certificates, | ||
| issue_rating_milestones, | ||
| ) | ||
|
|
||
| ratings_after = {result.user_id: result.rating for result in results} | ||
| issue_contest_certificates(contest, ranks, ratings_after, notify_users=notify_users) | ||
| for user_id, rating in ratings_after.items(): | ||
| issue_rating_milestones(user_id, rating, notify_user=notify_users) |
There was a problem hiding this comment.
Bug: Certificate issuance runs outside the rating transaction, causing permanent certificate loss if post-commit issuance encounters an error.
Severity: MEDIUM
Suggested Fix
Wrap certificate issuance within a transaction or error handling mechanism that allows retrying certificate generation independently if ratings are already committed, rather than returning early without issuing certificates.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.
Location: backend/ratings/services.py#L136-L147
Potential issue: In `compute_contest_ratings`, rating computations and history rows are
committed inside an atomic transaction, while certificate issuance
(`issue_contest_certificates` and `issue_rating_milestones`) runs afterward outside the
transaction block. If an unhandled exception or connection error occurs during
certificate generation or notification dispatch, the task fails with ratings already
committed. On subsequent retries or manual re-finalization attempts, the
`RatingHistory.objects.filter(contest=contest).exists()` guard returns early, preventing
certificate issuance from running again automatically.
| ratings_after = {result.user_id: result.rating for result in results} | ||
| issue_contest_certificates(contest, ranks, ratings_after, notify_users=notify_users) | ||
| for user_id, rating in ratings_after.items(): | ||
| issue_rating_milestones(user_id, rating, notify_user=notify_users) |
There was a problem hiding this comment.
Bug: Milestone certificates are issued using internal Glicko ratings instead of display ratings, causing new users to receive certificates for thresholds higher than their public rating.
Severity: MEDIUM
Suggested Fix
Convert result.rating to display_rating before passing values to issue_rating_milestones, taking rating suppression into account. Update audit_rating_milestones and backfill functions to inspect display rating instead of UserRating.rating.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.
Location: backend/ratings/services.py#L144-L147
Potential issue: Milestone certificates are evaluated and awarded using internal Glicko
ratings (`result.rating`) instead of user-facing display ratings (`display_rating`).
Because newcomers have a high suppression value applied to calculate display ratings
(e.g., starting at internal 1500 with a -400 suppression), a new user can earn milestone
certificates for thresholds like 1200 or 1400 after their first contest despite their
public profile and rating band displaying around 1100. Similarly, audit and backfill
routines compare thresholds against internal ratings rather than display ratings.
Also affects:
backend/certificates/services.py:205~216
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 41530a0f5b
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if had: | ||
| revoke_contest_certificates( | ||
| contest, reason=f"contest {contest.id} ratings discarded by organizer" | ||
| ) |
There was a problem hiding this comment.
Reconcile replayed contests' certificate snapshots after rollback
When an earlier contest is discarded, replay recalculates the post-contest ratings of surviving later contests, but this hook revokes participation certificates only for the discarded contest. During replay, issue_contest_certificates reaches _issue, which returns immediately for existing rows without reconciling their snapshots. Consequently, later contests' certificates remain VALID while displaying a details.rating_after that disagrees with the rebuilt RatingHistory. Reconcile affected surviving certificates as well, either by correcting their snapshots with an audit trail or revoking and replacing them.
Useful? React with 👍 / 👎.
…asedpyright gate)
| revoke_contest_certificates, | ||
| ) | ||
|
|
||
| if had: | ||
| revoke_contest_certificates( | ||
| contest, reason=f"contest {contest.id} ratings discarded by organizer" | ||
| ) | ||
| audit_rating_milestones() | ||
| if not had: | ||
| return f"contest {contest.id} was never rated; nothing to roll back" | ||
| return f"contest {contest.id} discarded (was rated); {replayed} contests replayed" |
There was a problem hiding this comment.
Bug: rollback_contest_ratings does not update rating_after in existing contest participation certificates during contest replay, leaving certificates with stale rating snapshots.
Severity: MEDIUM
Suggested Fix
In _issue or rollback_contest_ratings, ensure existing participation certificates for replayed contests have their details['rating_after'] snapshot updated when ratings are recomputed, or revoke and reissue them alongside discarded contest certificates.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.
Location: backend/ratings/services.py#L175-L193
Potential issue: During `rollback_contest_ratings`, all rating histories and user
ratings are cleared and subsequently replayed chronologically across all remaining
contests via `compute_contest_ratings`. This recalculates ratings and invokes
`issue_contest_certificates`. However, `_issue` uses `get_or_create` with certificate
snapshots passed in `defaults`. Because participation certificates already exist for
subsequent replayed contests, the defaults are ignored and the certificates remain
marked VALID with outdated `rating_after` snapshot details, causing certificates to
contradict recalculated rating history.
Also affects:
backend/certificates/services.py:59~71
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cdb082f616
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| for user_id, acc in per_user.items(): | ||
| if acc["solved_count"] >= 10: | ||
| recompute_solve_milestones(user_id) |
There was a problem hiding this comment.
Audit solve certificates after corrected verdicts reduce solve counts
If an admin rejudges a previously accepted submission and it becomes non-AC, running recompute_all_leaderboards() corrects the user's solve count but leaves any now-unsupported solve certificates VALID. This loop only issues certificates, skips users below 10 solves, and never visits users whose leaderboard rows were deleted. For example, a user whose tenth solve was invalidated still presents a valid ten-problem certificate after the full rebuild. Audit existing solve certificates against the corrected results and revoke unsupported milestones with a reason; invalidated judging results should not be treated as genuine lifetime achievements.
Useful? React with 👍 / 👎.
| ) | ||
|
|
||
| ratings_after = {result.user_id: result.rating for result in results} | ||
| issue_contest_certificates(contest, ranks, ratings_after, notify_users=notify_users) |
There was a problem hiding this comment.
Derive participation certificates from the official standings
When a rated contest includes registered users who never submitted, this call issues them final-rank certificates despite their absence from the official standings. _final_rank_map() includes every non-DQ, non-virtual registration, even without any ContestScore rows, whereas ContestViewSet.standings() uses contests/awards.py::build_ranking_input(), which includes only users with score rows. These extra registrations can also shift zero-score contestants' certificate ranks relative to the displayed board. Derive certificate recipients and final ranks from the official standings rather than assuming the rating rank map represents the same field.
Useful? React with 👍 / 👎.
|
On the failing This PR adds zero errors to that gate — all certificates-app errors were fixed in-repo (single-string member form + Optional narrowing, commits cdb082f/d36114e4). All other checks are green, including the required |
What
Closes the last two open Wave D roadmap items (
docs/ROADMAP-COMPETITIVE-2026-10-22.md§7 Wave D):1. Certifications/attestations — the honest variant
Roadmap: "certifications/attestations (HackerRank/PAST pattern — only with brand recognition; a leaderboard-derived 'atestat' is the honest variant)"; also closes backlog H-120 (verifiable participation certificates; contest medals were the Wave C first increment).
certificatesapp:Certificatemodel — public UUID verify id,WC-YYYY-NNNNNNserial, frozen bilingual achievement snapshot, revocation ledger with reason. Three derivations, all leaderboard-derived (never self-paced/unproctored):CONTEST_PARTICIPATION— every official (non-virtual, non-DQ) finisher of a rated contest, final rank + post-contest rating.RATING_MILESTONE— 1200/1400/1600/1800/2000 crossed in rated competition (peak semantics: an organic later drop never un-earns it).SOLVE_MILESTONE— 10/50/100/250/500 distinct APPROVED problems solved (same derivation as the leaderboard, so they can never disagree).compute_contest_ratingsand the post-ACupdate_user_entry/recompute_all_leaderboards;rollback_contest_ratingsrevokes the discarded contest's atestats and audits milestones the replayed ladder no longer supports;manage.py issue_certificatesbackfills idempotently (--notifyopt-in — replaying history never spams)./api/v1/certificates/): anonymousverify/<uuid>/(the H-120 employer/teacher flow — answers VALID or REVOKED with reason, never vanishes),mine/(paginated), public?username=(LEG-2: minors + inactive holders never surface), admin<pk>/revoke/(reason mandatory, first reason sticks)./certificatesholder list +/certificates/[uuid]anonymous verification page with a printable atestat sheet (UXP-3 print CSS — Print / save-as-PDF); navbar account-menu link;certificate_issuednotification rendering + deep link; 27 keys hand-written across all 28 locale catalogs (+ public copies re-exported; parity test green).2. Open-source/self-host decision
docs/DECISION-OPEN-SOURCE.md— the roadmap's "DMOJ/infoarena precedent vs maintenance cost" call: closed platform pre-traction, rolling component-level OSS (K-149), no self-host support, content portability as the community promise, with an explicit reopen path and review triggers.Also updates the trackers (BACKLOG.md, ROADMAP.md layers 0/4/5/6/8), API-MAP.md and RUNBOOK.md (Wave D operator notes:
issue_certificates,TWO_FACTOR_ENFORCE).Wave D verification (already-merged items re-verified this wave)
JDG-2 #239, OPS-2 #240, OPS-6 #241, EDU-10 #242 (+ LEG-2 gate #228), OPS-7 groundwork #243 (no paid flows), ANA-1 #244, COM-4 #246/#247, COM-5 #257 — each checked against its roadmap design text and covered by the green suites above.
Gates (run in this worktree)
SECRET_KEY=x DB_PASSWORD=x ...python -m pytest -q→ 1784 passed, TOTAL 100% coverage, zero warningsnpm run test→ 665 passed (82 files) ·npx tsc -p tsconfig.json --noEmit→ clean ·npm run lint→ 0 problems ·npm run build→ clean ·npx playwright test→ 136 passed / 3 skipped (pre-existing skips)risk:low