ADR-011 — Reconciliation reuses the idempotent webhook handlers¶
Status¶
Imported from heig-classroom (2026-09-30, merge task M0-03, ADR-035),
where it is ADR-011; Quiz's 011 slot was free (only 001–010 and 012 had been
inherited), so it keeps its number. The body below is classroom's,
verbatim. Read it with the renames of the merge: assignment ⇒
project; GradeRun ⇒ grade run; classroom's ADR-003 is Quiz's
ADR-003 (the same record); the cron jobs are ported as Quiz periodic tasks
(reconcile.grades, reconcile.repos, reconcile.deliveries,
docs/merge/03-github-projects.md; they are stored as Quiz's
scheduled_tasks, D10 settled 2026-09-30, spec 05 §5.4, Clock) and
never call GitHub inside a ticker tick (invariant 5).
Classroom's requirement ids (GR-, GH-, NFR-, AU-) are those of
heig-classroom's specification; docs/spec/02-exigences-fonctionnelles.md
receives their Quiz form (M0-04), and until then they are read in
heig-classroom's docs/.
Status in heig-classroom: Accepted (2026-07-03, phase 3).
Amended (2026-10-05, merge task M3-06a, as reconcile.grades and
reconcile.repos are ported): the addendum below bounds what the two polls
read (24 hours after a repository's freeze unless a final review is pending,
the quiet rule), settles which 404 is terminal, when a reconciled head moves
the student's last commit, the daily re-invite, and the stop on a rate
limit. The Decision stands; the addendum narrows its point 3 for Quiz.
Context¶
GitHub can lose or delay webhook deliveries; some events do not exist at all (invitation expiry, GH-24). The specs require a catch-up: reconciliation of GradeRuns every 15 min (GR-07), daily reconciliation of branches, invitations and missed deliveries (GH-62). After a database restore (NFR-16), the state must resynchronize on its own. The classic risk is writing two state-update code paths (one for webhooks, one for polling) that drift apart over time.
Decision¶
- A structuring rule (borrowed from the robustness proposal): every piece of state has two arrival paths — webhook (nominal) and reconciliation (fallback) — but a single update code path. The reconciliation cron jobs build normalized events and invoke the same idempotent handlers as the webhook pipeline.
- Handler idempotency rests on the UNIQUE constraints of the schema (ADR-003): replaying an event, whatever its source, never produces a duplicate.
- The cron jobs retained:
reconcile.grades(15 min, GR-07),reconcile.repos(24 h, branches and invitations, GH-24),reconcile.deliveries(24 h,GET /app/hook/deliverieswith redelivery, GH-62), plus the maintenance tasks (purge, e-mails). - A deliberate exception: the receipt time of a push reconciled after the fact is
unknown — the conservative GR-14.3 rule applies (
after_deadline = trueif the deadline has passed), open to a teacher's arbitration.
Consequences¶
- A single state code path to test and maintain; the fallback polling cannot diverge from the nominal path.
- The idempotent design is also the recovery plan: after an outage or a restore, the cron jobs absorb the lost window on their own, with no special procedure.
- Polling stays limited to catching up (NFR-10): in nominal operation, everything arrives through webhooks.
Rejected alternatives¶
- A separate reconciliation code path: a double implementation of the state rules, with guaranteed drift in the long run; that is exactly the defect this rule prevents.
- Generalized periodic polling instead of webhooks: it would violate NFR-10 (rate limits, polling limited to catching up) and degrade the NFR-12 latency.
Addendum (2026-10-05, merge task M3-06a): the scope and the stops of the two polls¶
Decided by the product owner (points 1, 3 and 4) and the orchestrator
(points 2 and 5) as reconcile.grades and reconcile.repos are ported
(apps/api/src/modules/project/reconcile.ts, scheduled tasks of D10; the
pure rule is reconciles and isQuiet of @quiz/domain):
- The quota bound. A repository is polled while it is live
(provisioned, not deleted, its project not archived) and, once frozen
for good, for 24 hours after its freeze only — longer only while its
final review was asked (a
deadlinerow of the dispatch ledger) and no review run filled the slot. A reopen clears the freeze and brings the repository back. A finished project costs GitHub nothing after a day; the webhooks still reach it.reconcile.gradesfurther reads only the repositories QUIET for 30 minutes — no push received, no run completed — since while the webhooks flow there is nothing to catch up. A staff unlock after the freeze, without a reopen, does not widen the window. - The 404 rule. Both tasks locate a repository by its immutable id
first (
GET /repositories/{id}); a 404 there, once the installation's token was obtained, is the repository gone —markRepoDeleted(…, via: reconcile), terminal and audited once. A 404 anywhere else — a run listing, a branch head, an invitation — never marks one deleted: it is a failure of that step, logged, and the pass goes on. A name GitHub changed is followed through therepository.renamedwebhook's own path (followRepoRename, point 1 of the Decision). - No pusher means no head move. The reconciliation knows no push
sender. The default branch's head becomes the student's last commit
only when it is a person's: not recorded in
bot_commits, and its author and committer both named by GitHub and neither Quiz's App nor a workflow. A head GitHub attributes to nobody (the App's own commits carry no account), or only half (an author named, a committer not), never moves it. The product consequence: a student's commit whose author and committer e-mails are not linked to a GitHub account is never recovered by the reconciliation; only its push webhook (whose sender names the student), or a later attributed commit, moves the head. The reconciliation writes no push receipt (the intake's alone, ADR-012) and restores no protected file (the push webhook's). - The daily re-invite (F-PROJ-07) is claimed on the row
(
project_repos.invitation_reinvited_at, at most once a day) before GitHub is called, for a student's own repository whose invitation is pending and which is not frozen; the student is named by the login GitHub knows today for their linked account, as Accept and the staff's resend do. A repository not re-invited this pass has its collaborators looked at by the login recorded at the invitation, since GitHub sends no event for an invitation accepted late. A group's invitations wait for ADR-070's per-member follow-up. - Stop on a rate limit. Every request of a pass is made with Octokit's
noRateLimitWait(failFast): a quota exhausted stops the pass at once, logged and audited (project.reconciled,stoppedOnRateLimit), and the next period resumes it — every step being idempotent, nothing is done twice. Waiting it out inside the task would outlive the 30 minutes after which a scheduled run is taken for dead and claimed again (RUNNING_STALE_MINUTES), doubling the pass.
Addendum (2026-10-06, merge task M3-14k): the daily pass applies a missing protection¶
Decided by the product owner (M3-14, finding 17) after a pilot organization
moved from GitHub Free to Team once its students had accepted: provisioning
had tolerated the plan's 403 and nothing ever applied the hgc-protect
ruleset afterwards. A sixth step of reconcile.repos, scoped to a repository
that is live, not archived (read-only), with ruleset_id null and its
effective deadline still ahead on the server's clock, calls the very function
provisioning uses (protectStudentRepo, idempotent: an existing ruleset of
that name is adopted). Success stores ruleset_id (a conditional write) and
audits project_repo.protected; a plan restriction leaves it null silently
for the next day; a rate limit stops the pass (point 5); any other failure is
logged, the repository's other steps go on, and so does the pass. Up to a day of delay is accepted, and no staff
action nor migration is involved. The deadline's lock then uses its ruleset
instead of the archive (H8). The Decision and points 1 to 5 stand.