9. Task cards — delivered¶
Historical record, archived on 2026-10-09. These are the cards of the merge tasks whose row in
PROGRESS.mdisdoneordropped, moved here unchanged (same headings, same anchors) from09-tasks.md, which keeps the open cards and the brief to hand to an agent. Their "As delivered" notes record what each PR built at the time; the code, the spec and the ADRs say what holds now. Section references (§3.1…) point into thedocs/merge/folder.
M0 — Decisions and paper¶
M0-01 — Settle the blocking decisions¶
- Who: the product owner, with the orchestrating session.
- Goal: settle D01, D02, D04, D08 (after M0-02), D16; then the others as their phase approaches.
- Acceptance:
08-decisions.mdstatuses updated with date and place.
M0-02 — Measure production (read-only)¶
- Depends on: nothing. ‖ M0-01.
- Needs: the product owner's go for read access to both production databases (memory note: read-only psql through ssh) and to the GitHub App settings page.
- Goal: numbers that settle D04, D08, D09, D22 and I50:
- identity: users per side; overlap on
swiss_edu_id; among them, share with equaloidc_sub; classroom users with Keycloak-era subs; users withoutswiss_edu_id;anon-*users; addresses shared by two accounts after a union; - classroom content: classrooms (with org, owner, archived), pending staff
seats and assistants, assignments by state /
work_mode/ group mode / grading mode, repos, grade runs, journals and their pages/assets bytes, unprocessed webhook deliveries; - the live App's permissions and events (is
Secrets:readgranted?). - Output:
docs/merge/measures-2026-MM-DD.mdwith the queries and the counts, no personal data (the repository is public). - Acceptance: the file exists; D08 and D09 can be settled from it.
M0-03 — ADRs¶
- Depends on: M0-01 (D01, D16). ‖ M0-04.
- Goal: ADR-035 Accepted; ADR-029 status "Superseded by ADR-035";
classroom ADR-011 imported as ADR-011; classroom ADR-013/014/015
imported as ADR-047/048/049, the next free numbers (036–046 were taken;
bodies verbatim, a status line naming the former number and the
renames: assignment ⇒ project, …); status notes or addenda on 006
(project sweeps), 007 (applies again), 010 (GitHub + codespace secrets),
012 (literal vs analogical reading), 016 (codespace beside the runner),
027 (
sebsession per activity; cross-VM HS256 exception), 030 (project kinds);zensical.tomlnav. - Acceptance:
spec-challengerrun on ADR-035 has no open objection; docs build.
M0-04 — Spec amendments¶
- Depends on: M0-01. ‖ M0-03.
- Goal: the amendments listed in §7.5; 07 frozen as history; the unsettled decisions copied into 06.
- Acceptance:
spec-challengerpass; every term of §7.4 resolved in 01.
M0-05 — CLAUDE.md, AGENTS.md, reviewer prompts¶
- Depends on: M0-03.
- Goal: invariant 4 generalised (activity student views, and the
journal's student view as the journal's one exit: no draft, no page
before its
visible_from, no markdown, blob sha nor warning); invariant 6 gains the student branch (readableClassroom: a claimed enrollment reads its classroom, anyone else gets the 404); invariants 11–12 scoped toapps/runner(codespace's ownCLAUDE.mdcomes with M6-03); invariant 14 gains the project clause; the@heig-platform/uisentence replaced; layout gainsmodules/github|project|journal|gradebookandpackages/docrender;.claude/agents/invariant-reviewer.mdupdated accordingly; a pointer todocs/merge/for merge work. - Acceptance:
invariant-reviewerprompt names the new rules.
M1 — Foundations (prod-safe, invisible)¶
M1-01 — Classroom's pure domain into packages/domain¶
- Depends on: M0-03. ‖ M1-02, M1-03, M1-04, M1-06.
- Port from:
C:packages/domain/src/{grade.ts (extractGrade), finalGrade.ts, groupRepo.ts, repoName.ts}(journal part stays for M4-01),planDispatch,planMilestoneDispatch,runKind(C:dispatch.ts,C:grading.ts),parseStudentIgnore,slugify,resolveOffset; reuse Quiz's Zurich formatter. (rateLimitResetis transport knowledge: it stays in M1-02'sgithub/metrics.ts.) - Tests: the classroom unit tests of these files.
- Acceptance:
@quiz/domaingreen; no DB, nofetch. - Pitfall:
domain/roster.tsexists in both — keep Quiz's, port only test cases it lacks. - As delivered (#370): named with §7.4's words —
ciScore.ts(extractScore),finalScore.ts(resolveFinalScore,finalPoints),repoName.ts(repoName,slugify; nojournalRepoName; the results CSV filename now usesslugify),groupRepo.ts,reviewDispatch.ts(runKind,planFinalReviewDispatch,planCheckpointReviewDispatch,checkpointDueAt),studentIgnore.ts(parseStudentIgnore),zone.ts(SCHOOL_TIME_ZONE,zoneOffset,zonedIso= classroom'szurichIso;DRILL_TIME_ZONEgone); wire names unchanged (I16). Quiz's roster tests already covered classroom's.
M1-02 — GitHub adapter layer, configuration, image¶
- Depends on: M0-03. ‖ M1-01.
- Port from:
C:github/*(app, git, retry, lock, commit, revert, provision, collaborators, squash, studentize, sync, metrics) andverifySignature⇒Q:apps/api/src/github/.parseStudentIgnoreandzurichIso(aszonedIso) come from@quiz/domain(M1-01, I17), not local copies;rateLimitResetstays ingithub/metrics.ts. - Change:
Q:config.ts(sixGITHUB_*, off when empty, production refusals §3.3),Q:redact.ts(x-access-token:,gh?_),Dockerfile(git,ca-certificates), octokit + lockfile (atomic commit). - Tests: app, collaborators, git, metrics, provision, retry, studentize (≈ 58 cases); config refusal tests.
- Acceptance: green without network; the image has git; the API boots
unchanged with no
GITHUB_*.
M1-03 — ActivityKind and the ActivitySummary union¶
- Depends on: M0-03. ‖ M1-01.
- Goal:
Q:modules/activity/with theActivityKindinterface (listForTeacher,listForClassroom,studentCards,gradebookEntries,deadlines?), one implementation (evaluation);ActivitySummarybecomes a union withkind: "evaluation"(mode kept);activity_*notification payloads kind-neutral ({activityKind, activityId, activityTitle}) before they are built. - Acceptance:
/activitiesand every screen unchanged (screenshots identical); contract tests updated. - As delivered (#373):
ActivityKind<K>haskindandlistForTeacheronly, over a plainKINDSlist inmodules/activity/service.ts, which servesGET /activities. Each other member lands with the first task that calls it throughKINDS:studentCardswith M5-01,gradebookEntrieswith M5-03,deadlineswith M3-05 (wired to the ticker there), the classroom list with the task that first needs a mixed-kind classroom list. Rule: a method that takes a classroom id is reached only after the route has loaded the classroom throughstaffAccessorreadableClassroom; its reviewer checks it.activity_availableis kind-neutral (migration 0037; I58–I60).
M1-04 — Missing primitives and long-form styles¶
- Depends on: M0-05. ‖ M1-01…03.
- Goal:
GithubIcon,OrgAvatar,Progressinapps/web/src/ui/(DevGallery entries);.md-body.md-doclong-form styles and the "Long-form reading" section ofapps/web/DESIGN.md(restated in Quiz radii). - Acceptance: ui tests, gallery screenshots; the question
MarkdownViewscenes unchanged.
M1-05 — Web route and mock skeleton¶
- Depends on: M1-03. ‖ M1-04.
- Goal: routes
classroom(role-dispatched, the student branch a placeholder behind a flag),classroomSettings,studentCourses,classroomJournal,classroomGrades,project,projectGroups; mock filesmock/github.ts,mock/project.ts,mock/journal.tswith the flags?projects=1,?unlinked=1,?journal=1. - Acceptance: router tests (nested and encoded paths, trailing slash); default scenes unchanged.
M1-06 — Import script skeleton, identity matching, login adoption¶
- Depends on: D08 (after M0-02), D04. ‖ M1-01…05.
- Goal (narrowed by the product owner, 2026-10-01, D08 addendum):
Q:apps/api/scripts/import-classroom.tsper §2.5 — read-only source connection, mapping file schema (zod),--dry-run(default) /--apply,import_classroomschema (id_map,runs), a readable report; steps: users (the cascade of §2.4), addresses, claims, avatars, teacher grants, GitHub account links (spec 06 no. 45), course staff seats, enrollments merged into the mapped rosters, role recompute; mapping validation with the organization check. No course and no classroom is created (D22): each source classroom maps to an EXISTING Quiz classroom (course code + classroom name) or is dropped, and a mapped classroom must already be connected to the organization its source classroom used. Login adoption inQ:auth/login.ts+ its ADR (D08: required). - Fixture:
apps/api/scripts/fixtures/classroom-seed.sql, synthetic, anonymised, on classroom's own schema (its migrations, concatenated). - Tests (
*.db.test.ts): import into PGlite; second run writes nothing; merge into an existing classroom; a duplicate identity is reported, not merged silently; an unconnected or wrong-org classroom refused; a dropped classroom's users not imported; GitHub link cases; login adoption (each key, ambiguity, concurrency, never a non-classroom:row). - Acceptance: dry-run report readable; no write outside the target DB.
- Stays with M8-01: projects, journals, webhooks, the legacy audit, the parity report, the codespace, and the pre-flight checks on the source's state (stopped, queues empty, deadlines). M1-06 alone switches nothing (spec 06 no. 46: the complete import is the switch).
- As delivered (branch
merge/M1-06-import-identity): ADR-061 (login adoption:auth/adoption.ts, auditsauth.account_adopted,auth.adoption_ambiguous); migration0053_import_classroom(the schema; no index onswiss_edu_id, ADR-061 §7);importAccountLinkinauth/githubLink.ts; the script underapps/api/scripts/(run withpnpm --filter @quiz/api import:classroom;--actor <admin e-mail>names the account recorded on the run and on grants whose creator is not imported).--applyrefuses while the report holds a refusal (mapping incomplete or unresolved, a classroom not connected or connected elsewhere, an ambiguous identity, an inconsistent source) or while an open decision is not given:--assistants staff|skip(open item 4) and--missing-students enroll|report(open item 3). A matched account's avatar is Quiz's (profile), its claims the newer snapshot. - The identity rule is ONE pure function,
decideMatchof@quiz/domain(identityMatch.ts), used by the import and by the adoption; a private address must have exactly one verified holder in the whole database; a placeholder carrying aswiss_edu_idis adopted through that key only; a login whosesubstarts withclassroom:is refused. - Left out: heig-classroom's
dev:accounts (their placeholder would be adoptable by address); of an anonymized account, its addresses, claims and avatar (classroom's anonymization never cleared them). - Writes go through the owners' writers:
addAddressesandrecordIdpClaims(auth/claims.ts),importAccountLink,createTeacherGrant(modules/admin/service.ts),addStaff, andclaimLines(modules/org, the roster's own claim pass). The steps arescripts/import-classroom/steps.ts. - Decided at review (ADR-061 §8):
--actoris required and an admin; a grant is imported only on a verified address of an imported person.
M2 — GitHub substrate¶
M2-01 — github schema and contracts¶
- Depends on: M1-02, D02, M0-04, M0-05.
- Create:
Q:db/github.ts(github_organizations,github_classroom_links,github_accounts,webhook_deliveries,push_receipts), one additive migration;packages/contracts/src/github.ts; import-script steps for these tables. - Acceptance: migration runs on a production dump copy; idempotency indexes tested.
- As delivered (#402): tables in
Q:db/github.ts, migration0048_github(fiveCREATE TABLE, FKs tousersandclassroomsonly). Production-dump run waived by the orchestrator: create-only migration; staging applies it against a restored production dump at deploy. Choices the next tasks inherit: github_organizations: one column per fact. Installed or not isinstallation_idnull or not, nothing else;status(GITHUB_ORG_STATUSES:active/deleted) says whether the organization exists on GitHub. No CHECK, no avatar column, no timestamps. Rows are never deleted.webhook_deliveries.payloadis nullable: the purge sets it to null and keeps the row (the deduplication outlives it);received_atandpush_receipts.received_athave NO default, the intake writesapp.clock.now().push_receiptsis keyed ongithub_repo_id(GitHub's repository id), not on a project repository's row:githubowns no FK intoproject(spec 05 §5.3 amended). M3 joins throughproject_repos.github_repo_idand deletes a repository's receipts itself if it wants them gone.- Contracts (
packages/contracts/src/github.ts):GithubOrg{id, login,avatarUrl(the avatar route below, or null),installed,status,plan},GithubClassroom{link: GithubClassroomLink | nullwithchecks: GithubChecks {allRepositories: boolean | null, llmSecret},suggestedOrgId,installUrl},GithubAccountState(GET /app/api/me/github). The web mock serves its three GETs on them (contract.test.ts, CHECKED). - For M8-01 (no import script yet, M1-06 is off the journal track):
amended 2026-10-05 (product owner, D20/D22 addenda): organizations and
classroom links are NEVER imported — the teachers install Quiz's App and
connect their classrooms by hand, and the import refuses a mapped
classroom not connected to its organization. The first two lines this note
used to carry (
organizations→github_organizations,classrooms.org_id→github_classroom_links) are dropped; a change in the count of either table is a red line of the parity report. Still to import:users.github_*→github_accountsfor the rows with agithub_user_id, on the remapped user (two classroom users merged into one Quiz user with two GitHub ids: report, keep the newer link);webhook_deliveries: the last 30 days, all processed at T0;push_receipts:github_repo_idfromstudent_repos.github_repo_id(a receipt whose repository has none is dropped and counted), the id kept,ON CONFLICT DO NOTHING.
M2-02 — Installations, org link, lazy healing¶
- Depends on: M2-01. ‖ M2-03.
- Port from:
C:modules/classrooms.ts(setup URL,GET /orgs, the healing of §3.1). - Create:
Q:modules/github/{routes,service,events}.ts;GET|PUT|DELETE /app/api/classrooms/:id/github(connect / disconnect, staff only throughstaffAccess; the GET carries the checks of05-web.md§5.3 — installation and repository access, plan, LLM secret);/setup/github/installed(App JWT); auditgithub_org.*. - Org avatar: an organization's avatar is served same-origin (fetched
server-side and cached, or stored at installation), never loaded by the
browser from github.com (privacy: the viewer's IP). The web's
OrgAvatar(M1-04) takes that URL assrcand shows the initials without one. - Tests: healing, setup-URL idempotency, connect by a non-staff ⇒ 404.
- Acceptance:
invariant-reviewerclean; routes 404 when no App. - Contracts (M2-01):
GET /app/api/github/orgs⇒GithubOrg[];GET /app/api/classrooms/:id/github⇒GithubClassroom. This task writes thePUTbody,GithubConnectBody {orgId}(strict), incontracts/src/github.ts. The avatar route isGET /app/api/github/orgs/:id/avatar(:idthe row's uuid), the only valueGithubOrg.avatarUrlaccepts (GITHUB_ORG_AVATAR_PATH). Its source is derived fromgithub_org_id(avatars.githubusercontent.com/u/<id>), or kept in a column this task adds if it chooses to store it. - As delivered (#404):
Q:modules/github/{routes,service,events}.ts, registered inapp.tsonly whengithubApp(config)is non-null (as the kiosk plugin), so without an App no route exists. Choices the next tasks inherit: - Routes:
GET /app/api/github/orgsandGET /app/api/github/orgs/:id/avatar(teacher role, 403 otherwise);GET|PUT|DELETE /app/api/classrooms/:id/github(session +accessibleClassroom: 404 off the staff; PUT answers theGithubClassroom, DELETE 204; 409journal_attached(D28) orapp_not_installed,GITHUB_CONNECT_REFUSALS, worded inapi.ts);GET /setup/github/installed(GithubSetupQuery; 303 to/classrooms/<state>/settings?connect=1[&installed=<orgId>]whenstateis a uuid (amended 2026-10-06, M3-14b),/otherwise; 20 per 10 min per address, then 429). - The healing is cached in memory
HEAL_TTL_MS(60 s) per organization; the setup return drops it. The listing is not cached and is authoritative: an installation GitHub no longer lists is cleared. recordInstallation(db, installation, via)(service) is the one writer of an installed organization: M2-04'sinstallationhandler calls it. It matches bygithub_org_id, by login only a row without one (an import), and never takes over a row holding the login under another id: that row is markeddeleted, its login moved aside (I66). Adapters:listInstalledOrgs,resolveOrgInstallationand the newfetchInstallationreturnAppInstallation; an optionalReadOptions(HTTP_READ=noRateLimitWait) on the HTTP-read adapters.- Avatar derived, not stored: fetched from
avatars.githubusercontent.com(redirect: "error", 5 s,AvatarMimewhose bytes match, 256 KB cap throughreadCappedofcappedBody.ts), cached in memory 24 h; a failure is not kept. - Test support for M2-04:
github/testing.ts(the App key, the fake GitHub,orgsRoute);auth/testing.ts(the seb and impersonation sessions of the route walks, used byseb.db.test.ts,impersonation.db.test.tsandmodules/github/walks.db.test.ts). - Audit:
github_org.link|unlink|installation_resolved|installation_deleted|renamed|deleted. - SSE: the setup return hints
classroomsoncourse:<id>(the linked classrooms' courses andstate's), neverclassroom:.
M2-03 — GitHub account linking¶
- Depends on: M2-01. ‖ M2-02.
- Port from:
C:auth/github-link.ts,currentLogin()(#41). - Tests: UNIQUE conflict ⇒
?github=conflict; unlink; refused under impersonation and delegated sessions (ADR-034, ADR-027);github.renamed; the token never stored nor logged. - Acceptance: return to the
returnpath, not/. - Contracts (M2-01): the account's state is
GET /app/api/me/github⇒GithubAccountState {account, relevant}(the route name the web mock serves,?unlinked=1). This task writes the callback's closed return,GithubLinkOutcome(?github=linked | conflict | error), incontracts/src/github.ts. - As delivered (#403):
Q:auth/githubLink.ts, registered inapp.tsonly whilegithubApp(config)is on (off ⇒ every route 404). - Routes:
GET /app/auth/github/link?return=,GET /app/auth/github/callback,GETandDELETE /app/api/me/github(DELETEidempotent, 204). Link, callback and unlink takeownSessionGuard(Q:modules/guards.ts, the rule of Super Powers too,ownPortalSession): a delegated session or a Bearer token gets403 session_required; aseb/kioskone is nobody there (401, default deny). return:linkReturn=safeReturnTo, and/,/app/…or anything refused ⇒/settings. The callback appends?github=keeping the path's query and fragment; a bad, forged, foreign or expired state lands on/settings?github=errorwithout calling GitHub.return=is masked in the request log likenext=.- State: signed cookie
quiz_github_link(Path/app/auth/github, HttpOnly, Lax, Max-Age 600) carrying{nonce, userId, returnTo, expiresAt}, the expiry checked onapp.clock; cleared by every callback. - Through Octokit:
githubApp()builds the App with itsoauthclient;app.oauth.createToken, a user Octokit forGET /user, thenapp.oauth.deleteTokensent without being awaited. The token is a local ofreadAccount; a failure is logged as{name, status, message}only (aRequestError's request body holds the client secret, the code or the token). The callback's query is masked in the request log (redact.ts). Production refuses an App id withoutGITHUB_APP_CLIENT_IDandGITHUB_APP_CLIENT_SECRET(config.ts). linkedLogin(db, octokit, userId)(an installation client): the login, orGITHUB_ACCOUNT_STALE(@quiz/contracts,{error: "github_account_stale"}, the 409 body) for a deleted account or no link; other failures throw. Nothing calls it yet (M3-03, M4-03).relevantis staff of a classroom linked to an organization only (M2-08 adds a claimed seat in one); M3-01 adds "has or had a project" tolinkRelevant.- Not ported: classroom's
inviteOnGithubLink()(group repositories created before the link), which belongs to M3-03. github_accountsbelongs toauth(orchestrator's decision): written byauth/githubLink.tsonly (link, unlink, rename), though its schema sits inQ:db/github.ts.
M2-04 — Webhook intake, handler registry, delivery reconciliation¶
- Depends on: M2-02.
- Port from:
C:modules/webhooks.ts(intake, installation and organization handlers),reconcile.deliveries,purge.housekeeping. - Create: encapsulated raw-body plugin; HMAC; dedup; synchronous
receipt hook (
onReceipt) and event registry (onEvent) for the project and journal modules; queuegithub.webhook. - Tests: port
webhooks.db.test(401, duplicate ⇒ 200, installation deleted, org renamed/deleted); receipt written withapp.clock. - Acceptance: 200 in < 100 ms on a fixture; replay of an unprocessed delivery.
- Schema (M2-01):
webhook_deliveries(insertON CONFLICT DO NOTHINGondelivery_id;received_at=app.clock.now(), no default; the purge nullspayload, never deletes the row) andpush_receipts(keyed ongithub_repo_id,ON CONFLICT DO NOTHINGkeeps the first receipt). - As delivered (#407):
Q:modules/github/{deliveries,handlers,jobs}.ts, the route inroutes.ts. POST /webhooks/github, in a child plugin of the module (nofastify-plugin) whose only body parser is a*buffer parser;config: { sessions: [] }, so a cookie sent along is ignored. In order:verifySignatureover the raw bytes (401bad_signature, nothing parsed nor stored);GithubWebhookHeadersof the contracts (x-github-deliveryaz.guid(),x-github-eventrequired; 400bad_headers); the body a JSON object (GithubWebhookBody; 400bad_body); one transaction insertingwebhook_deliveriesON CONFLICT DO NOTHING(received_at=app.clock.now()) and, for a push on a tracked repository,push_receipts(the first kept); a duplicate answers 200{ ok, duplicate: true }and runs nothing; thengithub.webhook { deliveryId }and 200. Without a queue the delivery runs beside the request, not awaited; a failed enqueue is logged and still answered 200 (the reconciliation replays it).- Registry (module-global sets, idempotent, reached through
modules/github/service.ts):onEvent(event, handler)withWebhookHandler = (app, config, delivery: WebhookDelivery) => Promise<void>(delivery={deliveryId, event, action, payload, receivedAt}), andonReceipt(tracks)withReceiptTracker = (tx, githubRepoId) => Promise<boolean>: the project module (M3) says which repositories it tracks;githubwrites the receipt (its table),isBotfor<slug>[bot]andgithub-actions[bot]. - Worker
processDelivery: every handler of the event in order, thenprocessed_at(app.clock); a throw storesredactTokens(error)and throws a NEW error with that masked text only (nocause: pg-boss stores what a job throws); every log of a failure is masked too. Queuegithub.webhook(jobs.ts):standardpolicy, no dedupe (a processed delivery is a no-op),retryLimit: 5, backoff from 30 s; registered inapp.tsonly with an App. - Handlers:
installation, whatever the action, re-reads the installation from GitHub (resyncInstallation:fetchInstallation, thenrecordInstallation(…, "webhook"), or forgotten when gone or suspended), so a stale event replayed out of order cannot undo a later one; a suspended installation counts as not installed everywhere (orgInstallation);installation_repositories(drops the healing:allRepositoriesis not stored),organization(renamed→renameOrgby id, retiring a row holding the new login;deleted→markOrgDeleted). Each hintsclassroomson the linked courses. No new audit action: thegithub_org.*ones carryvia: "webhook". Classroom's rewrite of<org>/names on a rename is the project module's ownorganizationhandler (M3). - Scheduled tasks (
GITHUB_TASKS, catalog):reconcile.deliveries(daily, nothing without an App: local deliveries unprocessed for 30 min, past the queue's ~15 min of retries, replayed one at a time without a queue, 200 at most; GitHub's failed attempts of 24 h redelivered, 50 at most, skipping a guid already stored) anddeliveries.purge(daily: payload nulled 30 days after receipt once processed; the row stays).
M2-05 — Periodic tasks (scheduled_tasks)¶
- Depends on: D10 (settled). ‖ M2-02…04. Landed before M1-02: the
core carries Quiz's own housekeeping; the GitHub reconciliations join the
catalog (
SCHEDULED_TASKS,SCHEDULED_TASK_KEYS, en/fr names) with M3-06 and M2-04. - Port from:
C:tasks.ts, thescheduled_taskstable, its admin routes; ontoQ:ticker.ts(claim + enqueue oneveryMs). - Acceptance: a task runs once across a restart; run-now; admin API
under
requireAdmin.
M2-06 — Quiz's Apps (production, staging) and staging safety¶
- Depends on: M2-01, D23. ‖ M2-02…05.
- Goal: document the registration of Quiz's two GitHub Apps
(
docs/development/github-app.md: permissions of §3.4, events, URLs per environment) and create them (product owner's hands, both owned by the account that owns classroom's App; the staging App installed on a test organization only); secrets into the vault (ADR-010) and the two.env;staging-refresh.shnullsinstallation_id(and archives projects); tasks no-op without an App (a test); ADR-028 note. - Schema (M2-01): nulling
github_organizations.installation_idis enough. It is the one column that says "installed" (statusonly says whether the organization exists on GitHub), and no CHECK ties it to another column, so the refresh touches nothing else. - Acceptance: a staging refresh from a dump holding installations
leaves no reachable production installation; the production App
installed on one test organization answers the setup URL of
quiz.chevallier.io. - As delivered (
merge/M2-06-github-apps): the production App was registered by hand by the product owner before this task (2026-10-06); this task ships the means to create the others and the staging safety. Q:github/manifest.tsholds the App's settings once: the permissions (§3.4: classroom's plus organizationSecrets: read), the subscribed events (push,workflow_run,pull_request,member,repository,organization) and the always-delivered ones (installation,installation_repositories), the webhook and setup paths (the routes import them), the manifest and the sixGITHUB_*lines.manifest.test.tsfails when the events differ from everyonEvent("…")ofapps/api/src/, whendocs/development/github-app.mddiffers from the module, or when the lines would not boot production.pnpm github:app(apps/api/scripts/github-app.ts): GitHub's App Manifest flow from a one-shot page on 127.0.0.1; the key and the lines go to new 0600 files outside any git working tree (or the lines to stdout), the key is never printed; a webhook secret under 32 characters is replaced throughPATCH /app/hook/config.- The refresh's SQL moved to
scripts/staging-scrub.sql, tested on the real migrations (stagingScrub.db.test.ts). Schema note amended: since M3-09bsuspended_atsits besideinstallation_id("null when there is none"), so the scrub nulls both. It also archives every project and stops its groups at the project's level (groups_stopped_at,group_sync_due_atnull: the group-sync claim ignores the archive), marks the copied webhook deliveries processed (no replay byreconcile.deliveries) and drops thepgbossschema (production's queued jobs; pg-boss recreates it at start). - The no-App test:
deadline.db.test.ts— a due project, no claim, no job and no GitHub call from the ticker nor fromGITHUB_TASKSandRECONCILE_TASKS(reconcile.db.test.tsalready pinned the two reconciliations). - Owner and installations are separate (product owner, 2026-10-06):
both Apps are owned by
heig-tin-infoand set to "Any account" (--public); production'sheig-quizis installed on the course organizations and the pilot'sheig-quiz-classroom, staging'sheig-quiz-stagingon the test organizationheig-quiz-staging. Both Apps on one test organization is allowed. The rule: never install the staging App on an organization where production holds real work — staging's copied rows are matched by GitHub org id (recordInstallation), so it would act on real students' repositories; the scrub reduces this, it does not remove it. A project's source repository must be in the classroom's organization (classroomClient), so staging tests need it inside the test organization. - Also:
docs/development/new-server.md(a generic, public guide to a new instance on a fresh Ubuntu server), theGITHUB_*blocks of.env.prod.exampleand.env.staging.example, ADR-028's note.
M2-07 — Web: the classroom's Settings tab, GitHub section, link card¶
- Depends on: M2-02, M2-03 (contracts), M1-04, M1-05, D24.
- Goal: the teacher classroom's Settings tab (rename, archive, delete and the drill switch move there from the header, D24); its GitHub section — "Connect this classroom to GitHub" sheet (org picker, install, live status) and the checks of §5.3; header badge; the user Settings GitHub card (shown only when relevant), return toast, palette entry. §5.3.
- Checks (M2-01): the installation line is derived from the
organization (
installed,status: deleted) andchecks.allRepositories; the plan line fromorg.plan(free⇒ the warning, null ⇒ unknown); the LLM secret line fromchecks.llmSecret. The API sends each fact once. - Scenes:
classroom-settings,classroom-settings-github-connect|installed|checks-warn|org-missing,settings-github-linked|unlinked. - As delivered (#408):
classroomSettingsleftCLASSROOM_PAGES(it parses in every build) and rendersClassroomView routeTab="settings": a tab that is a route is an entry ofROUTE_TABS(ClassroomView.tsx), the others stay?tab=; M4-05 adds the Journal there. What the next tasks inherit: Q:apps/web/src/ClassroomSettings.tsx: sections Classroom (Rename in a one-field dialog, the drill switch —ClassroomDrillSettingis now a bareSettingRow), GitHub, the Journal slot (M4-05) — a comment between GitHub and "Archive and delete", then Archive / Delete (bothsecondary; Delete confirmsdanger). The header lost its overflow menu and its rename-in-place; it keeps the name, the period (still a dialog) and, when connected, an org badge.Q:apps/web/src/github/:api.ts(useClassroomGithub,useGithubOrgs,useGithubAccount,githubAbsent= the 404 of a platform without an App ⇒ no GitHub drawn,githubLinkHref(returnTo)— M3-13's "Link my GitHub account" uses it),checks.ts(the three lines, pure),ClassroomGithub.tsx(section + connect sheet; M3-11's "New project" on an unconnected classroom opens the same sheet by?connect=1on/classrooms/:id/settings),AccountCard.tsx,linkReturn.ts(useGithubLinkReturn, mounted once inApp.tsx).- Keys
classroomGithubKey(underclassroom),githubOrgsKey(rootgithub, added to theclassroomshint's roots),meGithubKey(underme). Palette:classroom-github-connectthroughuseScreenCommands. - Mock: PUT/DELETE of the link,
?ghwarn=1,?ghmissing=1; a disconnect of PRG1-2026 under?journal=1answers409 journal_attached.
M2-08 — Student link from Settings, login on the roster¶
- Depends on: M2-03, M2-07. Product owner, 2026-10-01: students may link before any project, discreetly.
- Goal: the user Settings card is also relevant to a student with a claimed seat in a classroom connected to GitHub (F-GH-05); no banner, no nudge, no line telling the student the staff see the login. The teacher roster shows each linked student's login (GitHub icon + login) when the classroom is connected (05-web §5.1).
- As delivered:
linkRelevant(auth/githubLink.ts) is staff of a connected classroom UNION a claimed seat in one (M3-01 still adds "has or had a project");RosterEntry.githubLogin(rosterView, joined fromgithub_accountsonly whengithub_classroom_linksholds the classroom: the stored login, followed on rename, never fetched per read); the web shows it under the e-mail (the table stays at seven columns). The classroom detail is a staff-only route, so no student payload carries it.
M3 — Projects¶
M3-01 — project schema and contracts¶
- Depends on: M2-01, M1-01, D05.
- Create:
Q:db/project.ts(§3.3 tables), one additive migration;packages/contracts/src/project.ts;ActivitySummaryandGradeRow(ex-ResultCard) project variants. The import-script steps for these tables moved to M8-01 (product owner and orchestrator, 2026-10-01): this task is schema and contracts only, and the schema accepts imported rows as they are. - Acceptance: migration on a dump copy; the partial uniques tested.
- As delivered (branch
merge/M3-01-project-schema): migration0054_project(nineCREATE TABLE). What M3-02…M3-16 inherit: - Tables (
Q:db/project.ts):projects,project_checkpoints,project_groups,project_group_members,project_repos,project_grade_runs,bot_commits,grade_dispatches,reverts; classroom's columns under §7.4's words (distribution_*,review_dispatched_at,released_at/by,review_grade_run_id,project_grade_runs.points/max/kindci|review,grade_dispatches.triggerdeadline|checkpoint+checkpoint_id, children's FKrepo_id; shastext). New:projects.org_id(copied from the classroom's link),grading_scale(no default),created_by;project_repos.released_points/max(the release's snapshot),protection_suspended_at. Codespace columns andwork_modedropped (D09). No default onaccepted_at,completed_at,reverts.created_at(services writeapp.clock.now()); idsrandomUUID()(Quiz has no UUID v7 helper). Cascades per D19; nothing ingithubis reached by a deletion. - Contracts (
packages/contracts/src/project.ts): the closedas constlists the schema uses,ProjectGradingScale(linear|score_is_grade, the sharedRounding),ProjectActivitySummary,StudentProjectCard.ActivityKindName,ActivitySummary,StudentActivityCardcarry the project member;GradeRowis a union ofEvaluationGradeRowandProjectGradeRow({kind, projectId, title, date = released_at, status: "released", score: {points, totalPoints, grade}}, only after the release, never the source, comment or repository, N-SEC-20). Each route's bodies come with its task (cards M3-02, M3-03, M3-05, M3-08). projectActivity(Q:modules/activity/project.ts) is inKINDSand lists nothing (M3-02 fills it). The web draws no project in the Activities section nor on the student's classroom page (M3-10, M3-13); the student's Grades draws a released project row.projectGrade(@quiz/domain): score → grade by the project's scale,fellBackwhenscore_is_grademeets a maximum other than 6.linkRelevant(auth/githubLink.ts) counts a project repository of one's own or of one's group.
M3-02 — Project lifecycle¶
- Depends on: M3-01, M2-02, D19.
- Port from:
C:modules/assignments/lifecycle.ts,actions.ts,github/squash.ts,github/studentize.ts. - Create:
Q:modules/project/{routes,service,lifecycle}.ts; loadersfindAccessibleProject; theActivityKindimplementation for projects; auditproject.*. - Tests: source missing, slug and squashed suffixes and the adoption of an empty leftover (no cleanup on GitHub, ADR-062), patch rules, publish, archive (the reopen moved to M3-05), 404 for non-staff; deletion leaves repositories.
- From M3-01: deleting a project (or its classroom) cascades every
projectrow but never reachespush_receipts, which isgithub's and keyed on GitHub's repository id: the deletion purges the receipts of its repositories through agithubservice function (N-DATA-03). FillprojectActivity(Q:modules/activity/project.ts) and writegrading_scale(defaultProjectGradingScale()unless the body sets one). WriteProjectCreateandProjectPatch(heig-classroom's zod rules, a strict body, no work mode) and the lifecycle's error codes (unassigned_students,strategy_frozen,publish_mode_frozen,source_not_found,distribution_failed,duplicate_slug) incontracts/src/project.ts. Define the column defaults (grace_minutes30,squash,lock,auto,manual) once, as named constants used by both the schema's.default()and the zod.default(). - Decided for it (product owner and orchestrator, 2026-10-02): nothing is ever deleted on GitHub, the row is inserted before the build (ADR-062); the source, branches and source strategy are fixed at creation (F-PROJ-03 amended); projects open to every teacher now (D26 addendum); the reopen moved to M3-05; the git runner asynchronous.
- As delivered (branch
merge/M3-02-project-lifecycle). What M3-03…M3-11 inherit: - Routes (
Q:modules/project/routes.ts, registered only with an App configured, every body a strict schema ofcontracts/src/project.ts):GET /app/api/classrooms/:id/projects[?archived=1]→ProjectActivitySummary[](drafts included; the archive instead when asked) — M3-10's contract;POSTsame path,ProjectCreate→201 ProjectSummary;GET …/projects/sources→ProjectSourceRepo[](the organization's repositories,-squashed[-N]and archived ones left out) andGET …/projects/sources/:repo→ProjectSourceDetail(branches, the default branch's tree capped at 800,suggestedProtectedamongPROTECTED_FILE_SUGGESTIONS) — M3-11's browser;GET|PATCH|DELETE /app/api/projects/:id(ProjectPatch→ProjectSummary; delete 204);POST /app/api/projects/:id/{publish,archive,unarchive}→ProjectSummary. - Access (
guards.ts):findAccessibleProject(db, caller, id)(projects → classrooms → courses,accessWhere(staffAccess)in the WHERE) andaccessibleProject,projectsClassroom— the caller's own portal session only (ownPortalSession): an impersonation, a Bearer token, a student, a teacher off the staff get the 404; aseborkiosksession is nobody (401). - Services (
Q:modules/project/service.ts):createProject,patchProject,publishProject(db, id, now, actor)— the ticker (M3-05) calls it withSYSTEM_ACTOR(audit.ts), auditedproject.auto_publish—,setProjectArchived,deleteProject,unassignedStudents(claimed non-staff seats in no group),projectSummary,classroomProjects,teacherProjects,listSources,sourceDetail, andfetchSource(sources.ts), the ONE reading of a source the creation and the browser share: in the classroom's organization by its immutable id, never a distribution repository (by name or held by a project). RefusalsProjectError(errors.ts, codesPROJECT_REFUSALS, statuses there);unassigned_studentscarriesstudents,source_not_foundmay carrybranches(no response schema yet: M3-11). The classroom's installation is thegithubmodule'sclassroomInstallation(db, classroomId)(follow-up: the journal'stargetOfinjournal/writes.tscould use it too). - Distribution (ADR-062): the row claims the repository (partial
UNIQUE
projects_distribution_repo_uq, migration0056) before anything is pushed, and gets itsdistribution_full_nameonly once it is built: that name is the markpublishProjectrequires (distribution_missing) — M3-05's auto-publish and M3-09's accept read the same mark.createSquashedRepotakes aclaimcallback and never adopts a public leftover.repoWorld()can refuse or stall pushes (refusePushes,stallPushes+release).actorOf(req)/SYSTEM_ACTORinaudit.ts, the tracer's actor rule for a service that audits itself. - Patch rules are
projectFieldRefusal/editableProjectFields(@quiz/domain),ProjectSummary.editablethe list for the form: a draft changes everything; once published, the name, the protected files, the deadline (while not applied;422 deadline_pastat or before now) and the deadline strategy (until the deadline,strategy_frozen); publication mode and durationpublish_mode_frozen; the start, grace, grading mode and scale, group mode and sizenot_draft. An unchanged value (isDeepStrictEqual) is never refused.patchProject(db, id, …)andpublishProjectboth re-read the row FOR UPDATE in their transaction. The J−n checkpoints do not follow a moved deadline yet (M3-05). - Purge:
purgeProjectReceipts(tx, { projectId } | { classroomId } | { courseId })in thegithubservice, called in the deletion's transaction bydeleteProject,deleteClassroomanddeleteCourse(org/service.ts): the receipts of every repository the projects gone reference (student, group, distribution, source), except those a remaining project still references. Nothing on GitHub. Fororgto import thegithubservice without pulling the notifications into its graph,redact.tsnow reads the secret paths fromauth/paths.ts. - Git:
gitRunneris asynchronous (execFile),commit.gpgsign=falsefor the bot;setRemoteBaseForTestsis the one seam of the remotes (it throws underNODE_ENV=production);squash.ts,sync.ts,provision.tsawait it. Tests push to local bare repositories (Q:modules/project/testing.ts,repoWorld()), the fake GitHub answering from what is on disk — M3-03 and M3-07 reuse it. PROJECT_DEFAULTS(contracts) feeds the columns' and the zod defaults;projectActivity.listForTeacherlists the staff's projects (drafts in, archived projects and classrooms out);studentCardsstays empty (M3-09).- Not done here: notifications and SSE on publish (M3-09), the reopen (M3-05), a GitHub failure while reading the source is a 500 (not mapped to a refusal).
M3-03 — Acceptance and provisioning¶
- Depends on: M3-02, M2-03.
- Port from:
C:modules/student.ts(accept),github/provision.ts,github/collaborators.ts,C:repos.ts. - Contracts: Accept's error codes (
github_not_linked,github_account_stale,app_not_installed,provision_in_progress,no_group,has_repo,revoke_failed), and the zod enums ofPROVISION_STATUSES/INVITATION_STATUSESwith the first payload that carries them. - Tests (fake octokit): idempotent accept,
provision_in_progress, anokrow survives a failure, teacher notified once, stale account ⇒ 409, renamed login followed, refused under impersonation. - As delivered (branch
merge/M3-03-project-accept; product owner and orchestrator, 2026-10-02): - Route:
POST /app/api/student/projects/:id/accept(routes.ts,studentRoute), answeringProjectAcceptance{status, fullName, invitationStatus}— the caller's own repository, nothing else (N-SEC-20; the test searches every student response for the source and the distribution). No student listing:studentCardsstays empty (M3-09). - Loader
studentProject/findStudentProject(guards.ts): a claimed STUDENT seat of the project's classroom (a staff seat is a 404), a project neither draft nor archived; by the caller's own portal session (ownPortalSession). An impersonation POST meets ADR-034's read-only403 impersonation_read_onlyfirst outside development, the loader's 404 in it; a Bearer token 404,seb401. A classroom that is archived takes no new work: Accept answers its 404 (product owner, 2026-10-02). - Installation:
projectInstallation(db, orgId)in thegithubservice, onprojects.org_id(not the classroom's current link); the one rule "installed and active" (installed) is shared withclassroomInstallation. - Service
acceptProject(Q:modules/project/accept.ts): the row first —okordeleted_atset (dead stays dead), it is the answer, no call to GitHub, even after the deadline. Then the project's refusals,acceptRefusal(project, now)of@quiz/domain(pure, unit-tested):not_started,deadline_passed(now >= deadline, no grace),no_group,distribution_missing; thengithub_not_linked,app_not_installed,github_account_stale(linkedLogin, given the link already read),provision_in_progress,repo_name_taken, all 409. GitHub failing is502 provision_failed— its login lookup included: the stored login is never used unconfirmed (renamed away, it may be somebody else's now). An invitation GitHub refuses (isInvitationRefused) is409 github_account_stale. Codes inPROJECT_ACCEPT_REFUSALS(contracts;has_repoandrevoke_failedleft to M3-15, which serves them), statuses inerrors.ts. - Provisioning in the request: the row inserted (
accepted_atthe server's clock) under the partial UNIQUE, then claimed and returned in oneUPDATE … RETURNING(provision_claimed_at, taken over after 5 min byapp.clock);repoName(slug, login). Allow-list (review round 1):provisionStudentRepo'sclaim(repoId, created)runs before any push, change or invitation — a created repository's id is written on the row at once; an existing one (422) is adopted only when its id is the row's own (a replay), elseRepoNameTaken→409 repo_name_taken, nothing touched on it (a login naming the distribution, a source, another row's repository).full_nameis written on success.markProvisionFailed(db, rowId, claimedAt, error)never touches anokrow nor a row claimed since by another holder, and returns true for the row's FIRST failure (provision_errorwas null): auditedproject.accept_failed{notify}, false for a refused invitation. provision.tslost its work mode (D09): alwayspush, nevernone.repoWorld()(modules/project/testing.ts) now answers matching refs, the default branch'sPATCH, rulesets (freePlanfor the 403), collaborators (collaborators,uninvitable).- Not done here: the staff's notification (M3-09), resend and re-invite (M3-06, M3-08, M3-09), groups and revocation (M3-15), the ruleset of a free plan surfaced to the staff (M3-05).
M3-04 — Ingestion and grading pipeline¶
- Depends on: M2-04, M3-03.
- Port from: the push / workflow_run / member / repository /
pull_request handlers,
C:grading.ts, reverts with cap. - Tests: port
grading.db.testand the rest ofwebhooks.db.test; bot commits ignored; GR-14.3. - Decided for it (product owner, 2026-10-02; points 5–8 by the
orchestrator): a restore puts back the distribution's CURRENT version
(F-PROJ-08 amended, ADR-062 addendum); a workflow's commit is checked
too; the cap suspends until the staff re-enable; the current score's
rule and the
fallbackrule (F-PROJ-10 amended); review runs stored, the review slot filled only once frozen (F-PROJ-11 amended); a long or forced push read through GitHub's compare; a rate limit waited out without a retry; the bots are Quiz's App andgithub-actions[bot]only. - As delivered (branch
merge/M3-04-project-ingest). What M3-05…M3-09 and M8 inherit: - Handlers (
Q:modules/project/webhooks.ts, registered byprojectPlugin, so only with an App):onReceipt(tracksRepo)(a push on aproject_repos.github_repo_idgets its receipt in the intake's transaction);push(a deleted branch ignored;github-actions[bot]⇒bot_commits(grader); a person's push ⇒last_commit_sha/at—head_commit.timestamp, else the receipt time; the App's push moves nothing; every push but the App's throughprotectFiles);workflow_run(requested/in_progressof an eligible run on the student's last commit ⇒ci_status = pending;completed⇒ingestCompletedRun, which checks eligibility);memberadded ⇒invitation_status = accepted;repositoryrenamed (a student's repository by id, and a project's source or distribution by its id, matched on the name part only — the owner may have changed first) / deleted (markRepoDeleted, terminal, auditedproject_repo.deletedonce; a deleted repository's later events change nothing);organizationrenamed ⇒ the<org>/prefix ofsource_full_name,distribution_full_name,project_repos.full_nameof the organization's projects, by its GitHub id (order-free besidegithub's own handler). A rename applies only where the stored name is the one it left (changes.repository.name.from,changes.login.from), so a stale replay changes nothing. Each drops the live-state cache entry (forgetRepoLiveState) and hints. - The last commit is the last STUDENT commit: only a person's push
moves
last_commit_sha/at; it is the ONE commit whose CI state the repository shows (isLastStudentCommit,grading.ts):pendingis set only for it, the aggregated state is computed only for it. - Who pushed:
pushedBy(config, login)(githubservice):app(<slug>[bot]),workflow(github-actions[bot]),person; the intake'sis_botis!== "person". - Grading (
Q:modules/project/grading.ts), ONE path for M3-06:ingestCompletedRun(app, octokit, ctx, run: CompletedRun)→ the new run's id or null (ineligible, or already there: no annotation read again).ctxisrepoContext(db, githubRepoId)(repos.ts:{repo, project, courseId}). EligibilityisEligible(db, ctx, run)(handed-out branch; head neither inbot_commits, any kind, nor received as a bot's push,push_receipts.is_bot— the review skips both); annotations of the run's own check suite only, GRADE throughextractScore, TESTS counters; another workflow ⇒fallback;parse_detailthe malformed message (≤ 500);after_deadlinebyreceivedLate(receipt, deadline, now);to_verifywhileprotection_suspended_at, or on a restored head; the CI state of the student's last commit (actions/runsof that sha). The pure rules are@quiz/domain'sprojectRuns.ts:GRADING_WORKFLOW_PATH,runKind(review, moved there fromreviewDispatch.ts, which saidllm),receivedLate,selectScoreRun(runs, restoredHeads): latestci, not late,ok— orfallbackwhile the repository has no other run at all — bycompleted_at, never a run on a restored head.refreshScoreSelectionwritescurrent_grade_run_id, andfrozen_grade_run_idtoo whiledeadline_applied_atis set andfrozen_atis not (inert until M3-05). Areviewrun: stored;review_grade_run_idonly whenok,successandfrozen_atset; never the current score. - Protected files (
Q:modules/project/protection.ts,Q:github/revert.ts):protectFileson a handed-out branch, a project with protected files and a distribution, a repository not suspended, a head not yet answered (reverts.head_sha); touched files from the payload, orchangedFiles(GitHub's comparebefore...after) when forced or 20 commits listed, every protected file for a new branch;revertProtectedFiles(the distribution's blob at the branch, skipped when the head's tree — read once, recursive — already has it) hands the restore commit tobeforeMove=recordRestore: in ONE transaction, the repository rowFOR UPDATE, the capMAX_RESTORES_PER_HOUR(5,reverts.created_atbyapp.clock) ⇒protection_suspended_at, auditproject_repo.revert_cap{files, head}, the move refused; else therevertsrow andbot_commits(revert). Then the non-forced move, auditproject_repo.restore{files, sha, head}, the head's stored runsto_verifyand the score reselected. A 422 race takes therevertsrow back and throws: the delivery is retried. - Hints: kind
projects(contracts,events.ts, webHINT_ROOTS.projects = ["classroom"]), tocourse:<id>and the repository's students'user:topics (hintRepo,repos.ts; a group's members byproject_group_members→enrollments), neverclassroom:(tested: no topic of another student, noclassroom:). - Rate limit (N-PERF-07):
processDeliverymeetingrateLimitReset(err)(github/app.ts, moved frommetrics.ts) with a queue sends the delivery again withstartAfterthe reset and returns: no retry spent, the error kept on the row;reconcile.deliveriesmay replay it before the reset, accepted (it is sent again for the reset).SendOptions.startAfter; the in-process queue holds it in an unref'd timer. - Migration
0057_project_ingest:project_grade_runs.parse_detail,to_verify;reverts.head_sha+ partial UNIQUE(repo_id, head_sha),reverts.covered_sha(the head the restore was built on, the parentrevertProtectedFilesreturns ascovered). The restored heads (restoredHeads,grading.ts) arehead_sha,covered_shaand every head with a push receipt on that branch from the tampering push's receipt to the restore'screated_at: their runsto_verify, never the score; a push after the restore counts again. Auditproject_repo.restore,project_repo.revert_cap,project_repo.deleted. isZeroShaandownerRepo(fullName)(Q:github/app.ts) replace the zero-sha regexes and thesplit("/")of the GitHub code.repoWorld()(Q:modules/project/testing.ts) now answers the Git Data API (contents, blobs, trees, commits, ref read, ref moved fast-forward only), compare, andcommit(fullName, branch, files)/read(...)for a push from outside the App.- Not done here: a push on a source repository and
pull_requestonsync/*(M3-07); the live cache's reads, the re-enable route, themultiplealert's view (M3-08); notices, mails, notifications, theorg_lostpath (M3-09); the deadline, freeze and dispatch (M3-05); the reconciliations (M3-06). The App must subscribe toworkflow_run,memberandrepository(M2-06).
M3-05 — split (orchestrator, 2026-10-02)¶
M3-05 (deadline, freeze, dispatch, review checkpoints) is split in two: M3-05a the deadline (scheduled publication, the deadline and its strategies, the freeze, the reopen, a repository's own deadline, the staff's lock), M3-05b the review (the final review's dispatch, the review checkpoints and their routes). The original card's notes are kept under the half that serves them.
M3-05a — Deadline, freeze, reopen, per-repository deadlines¶
- Depends on: M3-04, D13 (amended 2026-10-02).
- Port from:
C:deadline.ts,github/lock.ts,github/commit.ts, the ticker duties, the reopen ofC:modules/assignments/lifecycle.ts. - Tests: port
deadline.test; TestClock: single claim, rescheduling between sweep and run, resume after a crashed or exhausted job (lease), 404 terminal, archive fallback only where rulesets cannot exist, idempotent commit strategy, freeze at the effective deadline + grace, a repository's own deadline, reopen in the middle of a job, a staff unlock never locked again, reopen un-archives, J−n across the DST change, no HTTP inside a tick. - From M3-02 (orchestrator, 2026-10-02): the reopen (F-PROJ-09)
replaces M3-02's
409 deadline_applied: the locks lifted,deadline_applied_at,frozen_at,review_dispatched_at,reminder_sent_atcleared, the frozen and review slots emptied, thedeadlinedispatches forgotten, the runs requalified and each current score reselected; teacher scores and a release kept; auditedproject.deadline_reopened. Scheduled publication callspublishProject(db, id, now, SYSTEM_ACTOR), whose group guard the scan carries as SQL (heig-classroom'sgroupFormationComplete). A moved deadline (patch, publish) re-resolves the J−n checkpoints not dispatched. - From M3-03: a repository provisioned on a plan without rulesets has
ruleset_idnull: the deadline'slockfalls back to archiving it (H8), shown to the staff as degraded. - From M3-04: the provisional freeze is the current score's run; verify
the grace refresh of the frozen slot; a deadline commit does not move
last_commit_sha; bot commits of a deadline go tobot_commits(deadline)BEFORE the ref moves; a 404 ismarkRepoDeleted(…, via)withviawidened. - Decided for it (product owner, 2026-10-02; points 5–8 by the
orchestrator): (1) a repository's own deadline, an individual extension
(D13 addendum), and everything deadline-shaped per repository on its
effective deadline; (2) the
commitstrategy is best effort (N-PERF-07 bindslock); (3) a reopen un-archives, and the archive is only for a repository without its protection ruleset or a plan refusing rulesets, never a 5xx or a rate limit; (4) J−n in calendar days in Europe/Zurich, one rule in@quiz/domain; (5) claims and leases instead of queue singletons (#273), a standard queueproject.deadline, no GitHub call in a tick; (6) the reopen's unlocks onproject.deadline, never in the PATCH, a failed unlock retried; (7) the staff's lock and unlock per repository, with an exemption a resumed pass never overrides; (8) the freeze provisional at the effective deadline, definitive at the effective deadline + grace. The reminder (claim and send) moves to M3-09: the students see nothing yet, and claiming without sending would lose it. - As delivered (branch
merge/M3-05a-project-deadline). What M3-05b, M3-06, M3-08 and M3-09 inherit: - Migration
0059_project_deadline:projects.deadline_job_at(the lease);projects.frozen_atDROPPED with the indexesprojects_freeze_due_idxandprojects_review_due_idx(the freeze is a repository's; M8-01 maps heig-classroom'sassignments.frozen_atonto the repositories);project_repos.deadline_at(its own deadline),deadline_applied_at,frozen_at,deadline_committed_at,archived_at(H8),staff_lock(the staff's hand: true, false, null = the deadline decides); partial indexproject_repos_freeze_due_idx.ruleset_idstays the protection ruleset made at provisioning; the lock ruleset (hgc-deadline-lock) is found by name. - Rules: pure in
@quiz/domain(projectRuns.ts):effectiveDeadline,deadlineWantsLock(repo, strategy)(the staff's hand, else applied and strategylock),reopens(appliedAt, deadline, now). Their SQL twins, for the scans only, inQ:modules/project/deadline.ts:EFFECTIVE_DEADLINE,WANTS_LOCK; withCOMMIT_DUE,NEEDS_WORK(wanted ≠locked_at, or a commit due) andLIVE(provisioned, not deleted, project not archived — #476;isLiveits TypeScript twin).effectiveDeadlineMoved(tx, project, repoIds, now)— the ONE path of a moved deadline: reopens a repository applied and now ahead (markers, frozen and review slots,deadline_committed_at,staff_lock, itsdeadlinedispatches), drops a staff unlock, requalifies every run byreceivedLateagainst the new effective deadline (receipt: the earliestpush_receiptsof the head), reselects the score of the repositories reopened or whose runs flipped.projectDeadlineMoved(the patch) adds the project's reopen (published,deadline_applied_at,review_dispatched_atcleared; auditedproject.deadline_reopened{deadlineAt, previous, repos}), re-armsreminder_sent_aton ANY move, and re-resolves the checkpoints (rescheduleCheckpoints, in TS bycheckpointDueAt, now calendar days in Europe/Zurich throughaddZonedDays).publishProjectre-resolves them when it counts a duration.projectFieldRefusalreadsstatealone (locked= applied). - Ticker and job (
Q:modules/project/jobs.ts):PROJECT_TASKS(one tick taskproject.deadlines, 20 s, inTICK_TASKS; skips without an App): (1) scheduled drafts published bypublishProject(its row lock and refusals are the claim; an incomplete group project meetsunassigned_studentsand stays a draft; auditproject.auto_publish); (2)published→lockedat the project's deadline (auditproject.deadline_applied); (3) eachLIVErepository's provisional freeze at its effective deadline (frozen_grade_run_id := current_grade_run_id,deadline_applied_at; auditproject_repo.deadline_applied); (4) its definitive freeze at effective deadline + grace (frozen_at; auditproject_repo.frozen); (5) WITH A QUEUE ONLY, the lease of each project withNEEDS_WORK(free or older thanDEADLINE_LEASE_MS, 10 min) and oneproject.deadlinejob{projectId, lease}— without a queue the tick claims nothing, so a job never runs in the ticker's process. Hintsprojectsto the courses' staff only.runDeadlineJob: nothing unless the lease is the row's; four repositories at a time, each re-read before each step (lock — archive whereruleset_idis null or the plan refuses —, unlock — un-archive first —, or the commit),locked_at/archived_atwritten after the call as facts; the lease RENEWED after each repository (heldLease; the job stops when another took it over); a 404 ⇒markRepoDeleted(…, "deadline"); a failure backdates the lease (FAILED_RETRY_MS, the work claimed again some 30 s on, N-PERF-07) and throws, the queue does not retry (retryLimit: 0); a crash leaves the lease to expire; success gives it back. Also noted indocs/development/index.md: without a queue, GitHub follows a deadline only on a staff action. Auditproject.deadline_enforced{strategy, locked (an archive counted), unlocked, committed, deleted, failed}per pass that changed something (M3-09'sproject_deadline_appliedis worded from it), andproject_repo.archived(degraded).requestDeadlineWork(app, config, projectId)— the staff actions' claim; without a queue the job runs awaited in that request. - Routes (staff,
staffAccess, own portal session, loaderaccessibleProjectRepoinguards.ts, paramsProjectRepoParams; a Bearer token 404, an impersonation 403impersonation_read_only, the loader's 404 behind it):PUT /app/api/projects/:id/repos/:rid/deadline(ProjectRepoDeadline{deadlineAt | null};422 deadline_past; auditedproject_repo.deadline_set{deadlineAt, previous, reopened}),POST …/repos/:rid/{lock,unlock}(auditedproject_repo.lock|unlock{staffLock}), each answeringProjectRepoDeadlineStateafter asking for the deadline work;409 repo_unavailablefor a repository not provisioned, deleted, or of an archived project.PATCH /app/api/projects/:idwith adeadlineAtasks for it too (the reopen's unlocks).PROJECT_REFUSALSlostdeadline_applied, gainedrepo_unavailable. - M3-04 updated:
after_deadlineon the effective deadline, read with the project's and the repository's rows under a share lock in the insert's transaction (a deadline moving meanwhile comes first or requalifies the run);refreshScoreSelection(db | tx, { repo })writes the frozen slot by a SQLCASEon the row's owndeadline_applied_at/frozen_at(a refresh racing the definitive freeze never moves it); the review slot reads the repository'sfrozen_at.pushEmptyCommittakesisDoneandbeforeMove;lock.tsgainedsetRepoArchived;isPlanRestrictionis exported (provision.ts).repoWorld()deletes rulesets and archives (an archived repository refuses writes). - Not done here: the reminder (M3-09); the final review's dispatch and the checkpoints' routes (M3-05b); the views of a repository's deadline, lock and degraded state (M3-08); notices and mails (M3-09).
M3-05b — Final review dispatch, review checkpoints¶
- Depends on: M3-05a.
- Port from:
C:dispatch.ts,C:milestones.ts, the ticker duties. - Create: the review dispatch and the checkpoints' sweeps in
Q:modules/project/jobs.ts'sprojectTick(claim + enqueue only); aproject.dispatchqueue (claims and leases, ADR-064, not singletons). - Contracts:
ReviewCheckpoint{id, name, dueAt, offsetDays, dispatchedAt}andReviewCheckpointCreate(acriteria.yml-friendly name^[a-z0-9][a-z0-9_-]{0,49}$; a date or an offset of −365…−1 days, exactly one; heig-classroom'smilestones.ts), and their routes. - Tests: port
dispatch.test,dispatch.db.test; the ledger (grade_dispatches, claimedON CONFLICT DO NOTHINGbefore GitHub is called), no HTTP inside a tick. - From M3-04 (2026-10-02): the review slot — F-PROJ-11 is amended:
a dispatched run runs on the default branch's head, not on
client_payload.sha, so M3-04 fills the slot for anok,successreviewrun received once the repository'sfrozen_atis set, never by comparing the head with the frozen commit; the dispatch must record the sha it sent (grade_dispatches.sha) as the only trace of what was asked. - From M3-05a (2026-10-02): the final review is per repository — at
ITS definitive freeze (
project_repos.frozen_at, its effective deadline - grace), not the project's; a reopen already forgets the repository's
deadlinedispatches and empties its review slot (effectiveDeadlineMoved), and clearsprojects.review_dispatched_at. The J−n checkpoints are re-resolved byrescheduleCheckpoints(deadline.ts, calendar days) on every move of the project's deadline; a checkpoint's creation resolves its offset by the samecheckpointDueAt. ReuseLIVE, the lease pattern (renewed as the job goes,heldLease) and the bounded concurrency ofjobs.ts. There is noprojects.frozen_atany more, nor aprojects_review_due_idx: the review scan is overproject_repos(frozen, nodeadlinedispatch); whetherprojects.review_dispatched_atstill serves is this task's call. - Decided for it (product owner, 2026-10-02; point 5 by the
orchestrator): (1) at most once — the ledger row claimed before the
call, an unconfirmed row never sent again (a manual re-dispatch is a
later option); (2) only Quiz's review counts — the slot takes a review
run only when the App triggered it; (3) a repository archived as its
lock gets no review, without a ledger row, audited degraded, never
un-archived for it; (4) checkpoints against the project's deadline,
calendar days, to every live repository not yet frozen, on the last
non-bot receipt before the date,
due_past/due_after_deadline, void after an earlier deadline, delete refused once any ledger row exists; (5) per repository at its definitive freeze,nonenever, no frozen run no review, the effective deadline in the payload, the sha recorded, even withoutANTHROPIC_API_KEY; a separate lease and queue, no HTTP in a tick;review_dispatched_atdropped. - As delivered (branch
merge/M3-05b-project-review). What M3-06, M3-08, M3-09 and M8-01 inherit: - Migration
0060_project_review:projects.review_dispatched_atDROPPED (whether a repository's final review was asked is itsgrade_dispatchesrow),projects.dispatch_job_at(the dispatch lease). - Leases (
Q:modules/project/lease.ts, shared with the deadline):claimLeases(db, key, now, work, projectId?),ProjectJob,LEASE_MS,FAILED_RETRY_MS, and ONE frame for both jobs,runLeased(app, config, key, job, label, body): the lease compared, the installation's client,each(repos, settle)(four at a time, a renewal after each, a throw counted failed and logged; returns the failed full names), then lost ⇒ return, failed ⇒ backdated lease and throw, else released.runDeadlineJobandrunReviewJobare bodies in it.hintStaffmoved torepos.tsashintProjectStaff. - The dispatch (
Q:modules/project/review.ts): the tick's step 6,claimReviewWork(queue only) →project.dispatch(PROJECT_DISPATCH_QUEUE,retryLimit: 0) →runReviewJob: first the final reviews (FINAL_REVIEW_DUE:LIVE, gradedauto,frozen_atandfrozen_grade_run_idset,archived_atandprotection_suspended_atnull, nodeadlineledger row), then each checkpoint that fires (checkpointFiresof@quiz/domain; its SQL twincheckpointDueonly in the claim) toCHECKPOINT_TARGET(LIVE,deadline_applied_atnull — "not yet frozen" read as not even provisionally). Each repository: one transaction (lockedRepo: the projectFOR SHAREread once, the repositoryFOR UPDATEamong its target; then the checkpointFOR SHARE) re-reads it and claims the ledger row (createdAt= the clock,shasent); thenPOST /repos/{o}/{r}/dispatcheswith Octokit's retries off. Outcomes: accepted ⇒dispatched_at; a 4xx ⇒ the row deleted, the repository failed (lease backdated, job throws), a 404 ⇒markRepoDeleted(…, "dispatch"); no response or a 5xx ⇒ the row left unconfirmed, countedunconfirmed, not a failure. A checkpoint is markeddispatched_atafter a pass with no failure for it. Auditproject.review_dispatchedandproject.checkpoint_dispatched(checkpointId,name){dispatched, deleted, unconfirmed, failed}per pass that changed something;project_repo.review_skipped{projectId, reason: "archived" | "protection_suspended"}written byjobs.tsonly: the freeze step, or the deadline job archiving a repository already frozen. - The review slot (
grading.ts):CompletedRun.triggeredBy(pushedByofworkflow_run.triggering_actoralone; none ⇒person) andstartedAt(run_started_at; null ⇒ never). The slot fills for a parsed, successfulreviewrun only whenapp, notto_verify, and started at or after the repository'sfrozen_atand after itsdeadlineledger row'screated_at(a conditional UPDATE on the row as it stands). A student's own dispatch or re-run, a checkpoint's run, a pre-reopen run arereviewtraces. Theworkflow_runevent carries noclient_payload: a checkpoint's run STARTED after the final review was asked would pass;CHECKPOINT_TARGETleaves that only to a GitHub delay longer than checkpoint-to-deadline + grace (ADR-064). - Domain (
@quiz/domainreviewDispatch.ts):isVoidCheckpoint,checkpointRefusal(dueAt, deadlineAt, now),checkpointFires. - Checkpoints (
Q:modules/project/checkpoints.ts, staff,accessibleProject):GET|POST /app/api/projects/:id/checkpoints(ReviewCheckpointCreate→ReviewCheckpoint, 201; resolved under the project's row lock),DELETE …/checkpoints/:cid(ProjectCheckpointParams, 204, 404 for another project's). RefusalsPROJECT_CHECKPOINT_REFUSALS(own contract block):due_past,due_after_deadline(422),duplicate_checkpoint,checkpoint_dispatched(409). Auditproject_checkpoint.create|delete(subject the checkpoint,{projectId, name, dueAt, offsetDays}). - Tests
Q:modules/project/dispatch.db.test.ts; the review-slot case iningest.db.test.ts. - Not done here: notices and mails (M3-09); the staff's view of the
ledger and of void checkpoints (M3-08); a manual re-dispatch (later).
Without a queue (
JOBS_DISABLED=1) no review is ever dispatched (docs/development/index.md). A repository archived as its lock and later un-archived by a staff unlock after its freeze gets its final review then (it is due again: frozen, no ledger row, not archived).
M3-06 — Reconciliation of grades and repositories¶
- Depends on: M3-04, M2-05. ‖ M3-05, M3-07.
- Port from:
reconcile.grades,reconcile.repos. - From M3-03 (orchestrator, 2026-10-02): the daily re-invite of
F-PROJ-07 (a student still without access, at most once a day per
repository) is this task's, through
inviteCollaboratorwithpush. Two leftovers of Accept to reconcile (reviewers of M3-03): a repository GitHub created whose id never reached the row (the process died between the create and the update) leaves that student onrepo_name_takenuntil staff recover it; and a renamed account before success leaves a stray private repository, possibly with the student still invited. - From M3-04 (2026-10-02):
reconcile.gradesbuilds aCompletedRunfromGET /repos/{o}/{r}/actions/runs(the last 20 completed, any workflow) and callsingestCompletedRun(app, octokit, repoContext(…), run)— idempotent, a known run reads no annotation;completedAtis the run'supdated_at. A reconciled run has no receipt:isAfterDeadlinemakes it late once the deadline passed (GR-14.3).reconcile.reposwritesinvitation_statuslike thememberhandler and the head like the push handler (withoutprotectFiles: restoring is the webhook's). A rate limit inside a scheduled task is waited out by Octokit once; a task is not a delivery, so it is the next period that retries. Three narrow edges of M3-04's restored-heads window, left by its review (round 3): the window ends atreverts.created_at, taken before the ref read, so a head received in between (other thancovered_sha) escapes — bound it by the receipt ofcovered_shainstead; a sha first received on another branch makes the join take that branch — store the restore's branch onreverts; after a 422 whose retry finds nothing to restore, S's runs stay unflagged. Close them here, where the reconciliation reads the same rows. - From M3-05b (2026-10-02):
CompletedRungainedtriggeredBy(pushedBy(config, run.triggering_actor?.login)— the triggering actor alone, none is a person's) andstartedAt(run_started_at, null when absent): the listing ofactions/runscarries both, and a reconciled review run fills the review slot only under the same rule as the webhook's (App-triggered, notto_verify, started after the freeze and after thedeadlineledger row). The reconciliation never re-sends a review dispatch: an unconfirmed ledger row stays as it is. - From M3-08b (2026-10-04): the staff's resend of a pending invitation
(
resendInvitation,modules/project/invitation.ts) is INDEPENDENT of this task's daily re-invite: it claimsproject_repos.invitation_resent_at(once a minute) and the re-invite neither reads nor writes that column — the two never wait for each other. Both go throughinviteCollaborator(…, "push")and both makeinvitation_statusfollow GitHub's answer (acceptedon a 204). A repository re-enabled by its staff (protection_reenabled_at) is an ordinary repository again forreconcile.repos. - Split (orchestrator, 2026-10-05): M3-06a (this PR) is the two
scheduled tasks; M3-06b closes M3-04's three restored-heads edges
(the window bounded by the receipt of
covered_sha, the restore's branch stored onreverts, the runs of S after a 422 whose retry finds nothing) and the rule the product owner decided the same day: a 422 then nothing to restore ⇒ the run staysto_verify, with arevertsrow and no restore commit. - As delivered (M3-06a, branch
merge/M3-06-reconciliation): - Tasks
Q:modules/project/reconcile.ts,RECONCILE_TASKSin the catalog (reconcile.grades15 min,reconcile.reposdaily; keys inSCHEDULED_TASK_KEYS, namesadmin.task.reconcile.*en/fr), run by thesystem.taskworker; without the App they return at once. Onepassfor both: the live repositories in scope (reconcilesandisQuietof@quiz/domainprojectReconcile.ts: 24 h after the freeze unless adeadlinedispatch is unanswered; quiet 30 min for the grades, computed for that pass only), the installation clients of the candidates' organizations throughinstallationClients(access.ts, now exported: the one rule of "the App is not on this organization"), each wrapped byfailFast(github/app.ts:noRateLimitWaiton everyrequest;paginate,rest.*andgraphqldo not inherit it and no helper here uses them), each repository located byGET /repositories/{id}— a 404 there ⇒markRepoDeleted(…, "reconcile"); a new name ⇒followRepoRename(repos.ts, extracted from therepository.renamedhandler, which calls it) —, then settled; a rate limit stops the pass (logged, the summary says so), any other failure of a repository is logged and the pass goes on. Auditproject.reconciled(subject the task key,{repos, runsIngested, reinvited, accepted, heads, renamed, deleted, stoppedOnRateLimit}) per pass that changed something or stopped. reconcile.grades:GET /repos/{o}/{r}/actions/runs?status=completed&per_page=20under the current name, each run throughcompletedRun(grading.ts, the ONE mapping,RawWorkflowRunits schema — theworkflow_runhandler uses both) theningestCompletedRun; a 404 from the listing deletes nothing. "Activity" for the quiet rule is the latest of the push receipts and of the runs'completed_at(GitHub's clock, notcreated_at: the two clocks never mix).reconcile.repos: a student's own pending invitation claimed (invitation_reinvited_at, UPDATE … RETURNING, once a day, never once frozen) theninviteAccount(…, via: "reconcile")with the loginlinkedLoginreturns today (no link: skipped, the claim stands;github_account_stale: skipped and logged, the claim stands) andfollowInvitation; not claimed ⇒ the recorded live grants' logins looked for among the collaborators (GET …/collaborators/{login}, 204 ⇒accepted). Group repositories: head and runs like any row, invitations untouched (ADR-070, M3-15b). The default branch's head (GET …/commits?sha=<default>&per_page=1) moveslast_commit_shaandlast_commit_at(moveLastCommitofrepos.ts, the push webhook's write too) only when not inbot_commitsand its author AND committer are both named, bothUserand both persons (pushedBy); thenaggregateCiStatus(exported fromgrading.ts). No receipt, noprotectFiles.- Migration
0070_project_reconcile(invitation_reinvited_at), regenerated after 0069 (0069_group_sync, M3-15b-2a) so itswhenandprevIdchain after it.markRepoDeleted'sviaandInviteViagainreconcile. A grant withrevoking_atset (M3-15b-2) is left alone: never re-invited, never read for acceptance. - Tests
Q:modules/project/reconcile.db.test.ts(both tasks against the fake GitHub: ingestion once from either path, the quiet and scope rules, the 404 rules, the rate-limit stop, the re-invite claim across a day, acceptance found, frozen and unlinked, the head rules, the rename),grading.test.ts(the mapping),@quiz/domainprojectReconcile.test.ts(the pure rules). - Accept leftovers (product owner, 2026-10-05): reported only. A
row on
repo_name_takenstays so — a repository is the row's only if the row made it (rule of 2026-10-02), so no adoption by name; a stray private repository left by an account renamed before success is not looked for (I69). Nothing here finds it. - Interpretation taken (to confirm in review): "no pusher means no head move" is read as fail closed — a head whose author and committer GitHub cannot name is nobody's and never becomes the student's last commit; a student committing with an unlinked e-mail is left to the push webhook, whose sender names them. A staff unlock after the freeze (no reopen) does not widen the 24-hour window.
- M3-06b — As delivered (branch
merge/M3-06b-restored-heads; the three restored-heads edges of M3-04, settled 2026-10-05 by the spec challenge and the product owner, ADR-062 addendum 6): - The window's upper bound (
restoredHeads,Q:modules/project/grading.ts) isGREATEST(reverts.created_at, receipt(covered_sha)), aLEFT JOINon the covered head's receipt (created_atalone while it has none): a head received between the restore'snowand the covered head's receipt no longer escapes. The storedto_verifymay lag for a head received late;refreshScoreSelectionnow makes the column follow the set at every reselection (the restore's, each ingest's, andreconcile.grades(15 min), whoseingestRunsstep calls it per candidate repository, DB-only: a lagging flag is caught up within the period with no new run), and the score was never wrong:selectScoreRunreads the set.protection.tslost its own bulk update for it. - The window's branch:
reverts.branch text NULL(migration0073_project_reverts_edges, no backfill), written byrecordRestore; the join readsbetween.branch = COALESCE(reverts.branch, tampering.branch), so the rows written before keep their behaviour. Known limit: receipts are one per sha, so a head first received on A then pushed on B escapes B's window (per-branch receipts out of scope); a run belongs to a sha, only the window is per branch. - A 422 on the move (product owner):
reverts.revert_shanullable; the 422 setsrevert_shanull —covered_shakept, the head the attempt read — instead of deleting the row, reselects (the runs flagged at once) and throws (the delivery is retried). Theansweredcheck skips a null row;recordRestorefills it in throughON CONFLICT (repo_id, head_sha) DO UPDATE … WHERE revert_sha IS NULL(its restore, covered head, branch andcreated_atare the retry's) and its cap count ignores null rows; a retry that finds nothing to restore leaves the null row and still reselects (pending). A null row's window: the heads from its receipt tocreated_atwhencovered_shais set (the fix pushed after the read is outside), its head alone otherwise (aCASEupper bound in the window join). - Nothing to restore (review round 1, a gap of M3-04): the adapter
answers
RevertOutcome {head, restored}and exposes its one comparisonalteredFiles(octokit, {…, sha, paths})(commit, tree, the reference blobs: the paths whose blob differs from the distribution's); a push that touched a protected file whose head a clean head had overtaken when its delivery was handled (head !== push.after) is read atpush.after(the hit files only, on that path only): altered ⇒ a null row, head alone (covered_shanull,filesthe altered ones), its runsto_verify; identical ⇒ a fix delivered late, no row. A push whose own head is clean (head === push.after) writes no row; a refusedbeforeMove(the cap) writes none either. Limit: never- delivered tampered heads between S and the clean one count. - Tests (
ingest.db.test.ts, "protected files"): a head ondevinsidemain's window not flagged (S first received ondev); X received betweencreated_atand S2's receipt flagged, a push on the restore not; a 422 then a retry finding the fix (null row withcovered_sha= S, Sto_verifyat once, the fix the score, five restores still pass the cap, no restore audit); a 422 then a retry that restores (row filled,created_atthe retry's, a redelivery answered, S1 flagged); S, S1, 422, the fix (S and S1to_verify, the fix the score); S then the fix before S's delivery (null row, Sto_verify, the fix's own delivery writes no row and counts, a redelivery answered); S, F1 (the fix), F2, every delivery handled with head F2 (S's null row only, F1 and F2 count).failedDelivery(payload)besidehandled. The student leak test (studentView.db.test.ts,toVerify/to_verify) stays green.
M3-07 — Sync of the source repository¶
- Depends on: M2-04, M3-02, D12. ‖ M3-05, M3-06.
- Port from:
C:sync.ts,github/sync.ts. - Tests: new (classroom has none): fake octokit + a local bare repo.
- From M3-04 (2026-10-02): the source push (
source_ahead_sha) andpull_requestonsync/*handlers register beside M3-04's inQ:modules/project/webhooks.ts(registerProjectHandlers). A sync updates the distribution repository, which is also the restore's reference (ADR-062 addendum): after a sync, a protected file is restored to the new version. The sync's own commits go tobot_commits(sync)before the ref moves. Therepositoryhandler already follows a renamed source or distribution by id; a deleted one is this task's to surface. - From M3-12a (orchestrator, 2026-10-04): the project page says
primaryAction: "sync"as a sentence in its header (project.status.sync,projectStatusinproject/projectPage.ts) and draws no button for it. This task adds the Sync primary button inproject/ProjectPage.tsx(theactionsof itsPageHeader, beside thepublishbranch) on its route, and the-sync-bannerscene. - As delivered (2026-10-05,
merge/M3-07-source-sync; ADR-073, F-PROJ-12 amended): Q:modules/project/sync.ts:requestSync(POST /app/api/projects/:id/sync, 202ProjectSyncAccepted): thesync_job_atlease taken (409 sync_in_progress), the distribution repository updated IN THE REQUEST (github/sync.ts'supdateSquashedRepo: the squash commit saysProject update, never the source's sha; awholepush refused as non-fast-forward is409 source_rewritten, auditedproject.sync_failed, the lease given back;502 sync_failedotherwise),projects.source_headswritten, auditedproject.sync_requested, oneproject.syncjob sent (run in the request without a queue).409 project_archived,distribution_missing,app_not_installed; a draft syncs its distribution only; never refused for a source that is not ahead.runSyncJobinrunLeased(which now hands the body the installationtoken): every student repository, re-read before its push, skipped when not live, past its effective deadline or locked (syncSkips,@quiz/domain); the distribution's head recorded inbot_commits(sync)beforesync/<branch>is forced (left alone when already there); GitHub's comparebranch...sync/branchwith no file →up_to_date; else ONE pull request per branch (project_sync_prs, PK (repo, branch); the stored one reused while open, else found by head, else opened; a comment only when the head moved), English texts naming the distribution's sha. Outcome per repository (project_repos.sync_outcome,_at: opened / updated / up_to_date / failed / skipped); a failed repository recorded, the pass goes on, the frame gives the lease back (runLeased'sonFailure: "release": nothing re-claims a sync but the staff, at once); a push the repository refuses is probed: a 404 marks it deleted (via: sync). Thensynced_at,source_ahead_sha/source_pushed_at/source_aheadcleared only when nothing failed and the sha is one ofsource_heads; auditedproject.synced {opened, updated, upToDate, failed, skipped, failedRepos}.- Handlers registered beside M3-04's (
webhooks.ts, additive):sourcePush— a push on a SOURCE repository marks every non-archived project handing out that branch (drafts included) withsource_ahead_sha,source_pushed_at(the delivery's receipt) andsource_ahead[branch](GitHub's compare from the handed-out sha, null when unknown); a push whose head is the handed-out sha marks nothing;pullRequest— the App's pull requests fromsync/*only, state kept (open,merged,closed), a replay about an older number ignored (setWhere). The source repository gets no push receipt. - Contracts:
ProjectDetail.sync(ProjectSyncState:ahead {pushedAt, commits|null},inProgress,syncedAt,lastcounts),ProjectRepoView.sync(ProjectRepoSync: the default branch'sprand the lastoutcome/at),SYNC_OUTCOMES,ProjectSyncAccepted, refusalsproject_archived,sync_in_progress,source_rewritten,sync_failed.createSquashedReporeturnssourceHeads, written at the build. Migration0072_project_sync(regenerated after0071_rpn_calculator) (sync_job_at,source_heads,source_ahead,sync_outcome(_at),project_sync_prs; the oldsync_pr_number/statecopied into the default branch's row, then dropped). - Web: the Sync button (primary when
primaryAction === "sync", a secondary beside Publish or Release while the source is ahead,offersSync), "Syncing…" while in progress (refetch every 3 s), the commits ahead in the counts, the last sync's counts under the header, the row's pull request / outcome tag (syncTag,SyncBadge) and a sheet fact; mock?ahead=1, sceneproject-sync-banner; en + fr. - Tests:
sync.db.test.ts(nine cases over local bare repositories, the fake GitHub now answering pull requests and a compare by refs), the leak test extended with the source's sha and the sync's words,syncSkipsunit test, the page's and the rules' web tests. - Not done here: the import's mapping of
sync_pr_number(M8-01, the migration's copy stands for Quiz's own rows); a notification kind for a merged pull request (none, D18); the student's notice (M3-09c). - Deployment: the production App must subscribe to
pull_request(M2-06 listsworkflow_run,member,repository).
M3-08 — split (orchestrator, 2026-10-02)¶
M3-08 (teacher views, grades, release) is split in two: M3-08a the reads (the project page, a repository's runs, the live state), M3-08b the writes (the teacher's score, the release, the invitation resend, the protection's re-enable). The original card's notes are kept under the half that serves them.
M3-08a — Teacher views: the project page, the runs, the live state¶
- Depends on: M3-04, M3-05a.
- Port from:
C:modules/assignments/detail.ts(the detail table, the grade-run history), the final-score rule ofC:modules/grades.ts, the live-state cache (#37/#40). - Contracts: the grade runs' views (
GradeRunView,GradeRunListwith the current, frozen and review run ids; zod enums ofGRADE_RUN_KINDS,GRADE_RUN_PARSE_STATUSES,CI_STATUSES),ProjectDetail. - Tests: port
grades.db.testand the detail's; a deleted repo never calls GitHub; a rate-limited detail returns stored state at once. - From M3-03 (orchestrator, 2026-10-02): each repository's
invitation_statusandprovision_statusin the teacher's views. - From M3-04 (product owner, 2026-10-02): the views show a suspended
repository as "protected files in conflict" and every run with
to_verifyas "to verify" (a suspended protection, or a head whose protected files were restored — a head inreverts.head_sha), amultiplerun as an alert (it notifies nobody), amalformedrun with itsparse_detail. The live-state cache is already dropped by every project webhook (forgetRepoLiveState); the views'projectshint roots joinHINT_ROOTS.projects(web, M3-12). - From M3-05a (2026-10-02): each repository of the views shows its
own deadline when it has one (
project_repos.deadline_at) beside its effective deadline, its freeze (deadline_applied_at,frozen_at), its lock as GitHub holds it (locked_at) and the staff's hand (staff_lock: a lock or unlock asked may not have reached GitHub yet), and a lock that fell back to archiving (archived_at) as degraded (H8; also a repository provisioned without rulesets,ruleset_idnull).ProjectRepoDeadlineState(contracts/src/project.ts,repoDeadlineindeadline.ts) is the shape the three repository routes already answer; fold it into the repository rows. - As delivered (branch
merge/M3-08a-project-views). What M3-08b, M3-09, M3-12 and M5-03 inherit: GET /app/api/projects/:idnow answersProjectDetail(modules/project/detail.ts), a superset ofProjectSummary: every client parsing the summary keeps working; PATCH, publish, archive still answerProjectSummary. Staff only, throughaccessibleProject(invariant 6): a student, another teacher, an impersonation, a Bearer token get the 404 of a missing project (tested). N-SEC-20: never reused nor filtered for a student; the student's projection is M3-09's own. Added:releasedAt;primaryAction(publish|sync|release|none,projectPrimaryActionof@quiz/domain: an archived project none, a draft Publish, Release oncescoresFinal— graded (auto), every LIVE repository definitively frozen, at least one; deleted, never-provisioned and archived-project repositories do not hold it back — and not yet released or a score changed since; then Sync whenprojects.source_ahead_shais set, which M3-07 fills; Release comes before Sync, since once every repository is frozen a sync reaches nobody's score);counts{students (the roster, staff seats excepted), accepted, live, frozen (live and frozen), toVerify, alerts};liveStale;rows, the roster by name (ProjectDetailRow{student, repo | null}, null being "not accepted"), then the repositories whose student left the roster (enrollmentIdnull). A repository of a user who now holds a STAFF seat of the classroom is left out of the rows, the counts and the release's readiness (a staff seat is never a student's, ADR-018). Individual repositories only: a group's rows (members byproject_group_members) are M3-15's.- A repository's row (
ProjectRepoView) extendsProjectRepoDeadlineState, which gaineddegraded(archived_atset, or provisioned withruleset_idnull; the three repository routes answer it too;repoDeadlineState(repo, project)indeadline.tsis the pure half ofrepoDeadline):provisionStatus,provisionError,invitationStatus,acceptedAt,lastCommit(the stored STUDENT commit, M3-04),ciStatus,live,scores{current, frozen, review}(ProjectSlotScore: run id, points, max,grade; a run's parse status andto_verifyare the run list's),teacher{points, comment, gradedAt},final(ProjectFinalScore: points, max,sourceteacher | review | ci — I42 —, grade);released(the snapshot{points, max}once the project is released, else null);flags:protectionSuspended,toVerify(the run of one of the THREE SLOTS isto_verify; a run on a restored head, never in a slot, shows in the history only),multiple(ANY run of the repository: an alert does not fade),malformed(the LATEST run'sparse_detailwhen it is malformed, else null),deleted(stored, or the live read's 404),changedAfterRelease. A late run shows in the run list (afterDeadline), not as a row flag.multipleandmalformedcome from oneGROUP BYover the repositories' runs.alertscountsmultipleorprotectionSuspended. - Grades: every score converts with its own maximum through
scoreGrade(points, max, scale)(@quiz/domainprojectView.ts,projectGrade; null without a maximum),fellBackcarried.resolveFinalScore(finalScore.ts) is settled onreview: inputreviewScore, source"review",FINAL_SCORE_SOURCES; nollmis left in the project code.changedAfterRelease(released, final, snapshot)compares points AND max, a score that appeared or vanished included. GET /app/api/projects/:id/repos/:rid/runs→GradeRunList(loaderaccessibleProjectRepo): the newestGRADE_RUN_LIST_LIMIT(100) runs bycompleted_at, each aGradeRunView(kind, conclusion, branch, head sha, points/max, TESTS counters, parse status and detail,afterDeadline,toVerify,completedAt), and the three slot ids. Database only.- The live state (decided here):
readRepoLiveStatefor every LIVE repository (never a deleted one, nor one of an archived project),LIVE_CONCURRENCY(8) at a time withinLIVE_BUDGET_MS(1.5 s, the installation's token included). Past the budget the page answers with what it has andliveStale: true; the reads under way finish into the cache, none is started after it, and the next view (the client refetches shortly) finds them warm: a cold page of 100 repositories costs at most eight requests in flight and fills over a few refetches. A rate-limited installation answerslive: nullat once:isRateLimited(github/metrics.ts) is asked before the installation's token is even minted, until GitHub's reset. The live read only ADDS counters (ProjectRepoLive: commit count, check runs passed / total,stale) and is never written back: GitHub's head may be the App's (a restore, a deadline commit), the stored one is the student's — heig-classroom wrote the head back on a view.forEachLimitis duplicated on purpose (detail.tsandjobs.ts), so that M3-08a and M3-05b, built in parallel, never edit the same file: whichever of the two merges second imports it frommodules/project/lease.ts(which M3-05b creates) and deletes its own copy. - Not done here: the writes (M3-08b), the web page (M3-12), the
student's view (M3-09), a group's row (M3-15); heig-classroom's
/activityroute (the commit graph) is not ported, no card asks for it.
M3-08b — Teacher score, release, invitation resend, protection re-enable¶
- Depends on: M3-08a.
- Port from: heig-classroom's override, validate ⇒ release, invitation resend.
- Contracts:
ScoreOverride(PATCH …/repos/:rid/score, points 0…1000 or null, a comment ≤ 2000), the refusalsnot_frozenandgrading_none. The release callsprojectGradeand writesreleased_points/max. - From M3-03 (orchestrator, 2026-10-02): the staff's resend of an invitation (F-PROJ-07).
- From M3-04 (product owner, 2026-10-02): the re-enable route of
the protected files (F-PROJ-08), which clears
project_repos.protection_suspended_at, audited, staff only. Follow-up (here or later): store a repository's name without its owner (the organization isprojects.org_id), so theorganizationrename handler ofwebhooks.tscan go. - From M3-05a (2026-10-02): the
not_frozenrefusal of the release reads the repositories'frozen_at(a repository with a later deadline may not be frozen yet). - From M3-08a: the page's
primaryActionoffers Release byscoresFinal(@quiz/domain), and the release's own guard is the same rule;changedAfterReleaseand the snapshot's shape are there.resolveFinalScorestill gives a teacher score on a repository without a scored run a null maximum: decision (1) replaces that. - Decided for it (product owner, 2026-10-02):
- A teacher score's maximum: its own, given with it, when the
repository has no scored run (pass / fail only, malformed,
multiple); otherwise the CI score's maximum. Under
grading_mode: none, no teacher score and no release (409 grading_none). - The release waits until every LIVE repository is frozen
(deleted, never-provisioned and archived-project repositories do not
block);
409 not_frozenotherwise; no partial release. - After the release the student and the gradebook read the LIVE
final score, flagged "changed after release" when it differs from
the snapshot (
released_points/max); a re-release rewrites the snapshot, audited, without a new notification; no withdrawal. A reopen after the release is accepted (the teacher's score waits for the new freeze). - Re-enabling the protection: only the restores after the
re-enable count toward the cap; the runs flagged during the
suspension stay
to_verify; nothing is restored at the re-enable. - Invitation resend: staff only, a pending invitation only (409 otherwise), at most once per repository per minute (429), audited, independent of M3-06's daily re-invite.
- From M3-05b (product owner, 2026-10-02): each repository shows its
final review from its
grade_dispatchesrow (trigger = deadline): none and frozen with no frozen run ⇒ "no review"; none and archived as its lock, or with its protection suspended ⇒ "no review" as degraded (auditproject_repo.review_skipped,reason; a re-enabled protection makes the review due again); a 5xx is "not confirmed" too; a row withdispatched_at⇒ asked at that time, ofsha; a row without ⇒ "not confirmed" (claimed, GitHub's acceptance never recorded: a crash or no response — never sent again; a manual re-dispatch is a later option); then the review slot when the run came back. There is noprojects.review_dispatched_atany more (migration0060): derive a project's "review dispatched" from the ledger. The checkpoints list (GET …/checkpoints,ReviewCheckpoint) shows a checkpoint not dispatched whosedueAtis at or after the project's deadline as void (it never fires; deletable), and a deletion refused with409 checkpoint_dispatchedonce any ledger row names it. - As delivered (branch
merge/M3-08b-project-release). What M3-12, M3-09, M3-06 and M5-03 inherit: - Routes, all staff only through
accessibleProject/accessibleProjectRepo(invariant 6: a student, another teacher, a token get the 404 of a missing entity; an impersonation the read-only 403 of ADR-034 first, the loader's 404 where it may write; tested on every route). Refusal bodies areProjectRefusal({ error, message, … }); the codes joinedPROJECT_REFUSALS(ProjectErrorCode), the statusesmodules/project/errors.ts.PATCH /app/api/projects/:id/repos/:rid/score, bodyScoreOverride(strict){ points: number 0…1000 | null, max?: number > 0, comment?: string ≤ 2000 }→ProjectRepoScores{ scores, released, changedAfterRelease }— the row'sscoresandreleasedasProjectRepoViewcarries them, so the row is patched in place.409 not_frozenbefore the repository'sfrozen_at,409 grading_none,422 score_max_required(no scored run and nomax),422 score_max_mismatch(amaxbeside a scored run's that differs),422 score_above_max.points: nullclears the score, its maximum and its comment. Auditedproject_repo.grade_override(payload.before,after:{ points, max, comment } | null).POST /app/api/projects/:id/release→ProjectReleaseResult{ releasedAt, first, repos, scored }.409 grading_none;409 not_frozenwithliveandfrozencounts in the body while a live repository is not frozen, or none is;409 to_verifywithrepos(ids) while a repository's final score rests on a run to verify (F-PROJ-14 amended 2026-10-04: the teacher's score settles it;ProjectFinalScore.toVerifysays which,scoresFinaltakes anunverifiedcount so the page offers no Release meanwhile) — over the LIVE repositories only: a non-live one (deleted, archived project) never freezes, so its score to verify is released as NO score (releasableScore,detail.ts: the snapshot null, and thechangedAfterReleasecomparison reads the same, so it never re-offers Release). Auditedproject.release(first,repos,scored).POST /app/api/projects/:id/repos/:rid/protection→ProjectRepoProtection{ reenabledAt: iso | null }; a no-op on a repository not suspended (its last re-enable, or null);409 repo_unavailable(not provisioned, deleted, archived project). Auditedproject_repo.protection_reenabled(payload.suspendedAt).POST /app/api/projects/:id/repos/:rid/invite→ProjectInvitationResent{ invitationStatus, resentAt }.409 invitation_not_pending,409 repo_unavailable(not provisioned, deleted, archived project:liveRepoForUpdate),429 resend_too_soon(less than a minute after the last resend),409 github_account_stale(the student's account is gone or renamed: they relink),502 invite_failed. Auditedproject_repo.invite_resent(payload.login,invitationStatus).ProjectUnassigned({ error: "unassigned_students", message, students: [{ enrollmentId, nom, prenom }] }) is the409body of Publish, for M3-12.
- The teacher's score has its own maximum (decision 1):
project_repos.teacher_max, written WITH the points — the scored run's maximum when the repository has one (the run the final score would otherwise come from: the review slot, else the frozen, else the current;resolveFinalScorewithout the teacher), the teacher's own otherwise. The score is then self-contained: a later run with another maximum never re-reads it.resolveFinalScoretakesteacherMax(teacherMax ?? review.max ?? ci.max ?? null); a nullteacher_max(heig-classroom's imported rows) keeps reading the CI's, as before.teacherScoreMax(points, given, runMax)in@quiz/domain/finalScore.tsis the rule; a run to verify is no scored run (runMaxnull): the teacher gives their own maximum.ProjectRepoView.scores.teachergainedmax;resolveFinalScoreandProjectFinalScorecarrytoVerify(the run behind the score is to verify; never for a teacher's). - The release (decisions 2 and 3):
releaseProject(modules/project/grades.ts) reads the page's own set and counts —studentRepos(db, project)(repos.ts: every repository but those of a user who now holds a staff seat) andreleaseCounts(project, repos)(deadline.ts: live, frozen) — and refusesto_verifywhile a final score rests on a run to verify (above). It writesreleased_points / released_maxfor EVERY repository of that set — a deleted repository with a score gets its snapshot too, so its grade is released andchangedAfterReleasestays false. The grade is not stored:scoreGrade(points, max, scale)on read, everywhere (M5-03 reads the snapshot that way). The readers keep the live final score;changedAfterReleaseis the flag, which a release again clears. M3-09's hook:ProjectReleaseResult.first(and the audit'spayload.first) is true on the first release only;project_grade_finalgoes out then and never on a release again — the place is marked inroutes.ts. - The protection re-enabled (decision 4):
reenableProtection(protection.ts) clearsprotection_suspended_atand writes a new columnproject_repos.protection_reenabled_at;recordRestore's cap countsreverts.created_at > protection_reenabled_atonly, within the hour. Nothing restored at the re-enable, the runs flagged meanwhile keptto_verify.FINAL_REVIEW_DUEalready requiredprotection_suspended_at IS NULLandreview_skippedwrites no ledger row, so the final review is due again on its own (tested throughclaimReviewWorkand the page). - The resend (decision 5):
resendInvitation(invitation.ts), the minute claimed on a new columnproject_repos.invitation_resent_atbefore GitHub is called and given back when GitHub's part fails (not once GitHub accepted: the status and the audit then land in one transaction);linkedLoginnames the student (today's login by the immutable id);inviteCollaborator(…, "push"). The row'sinvitation_statusfollows GitHub's answer (acceptedon a 204). Apart from M3-06's daily re-invite, which neither reads nor writesinvitation_resent_at. - The final review on the row (M3-12's challenge, 2026-10-04):
ProjectRepoView.review=ProjectRepoReview{ status: "pending" | "none" | "skipped" | "unconfirmed" | "asked" | "done", reason: "no_frozen_run" | "archived" | "protection_suspended" | null, askedAt: iso | null, sha: string | null, runId: uuid | null }, derived byreviewState(@quiz/domain/projectView.ts) from the row and itsgrade_dispatchesrow (trigger = deadline), read in one query bydetail.ts: the review slot filled ⇒done; a ledger row ⇒asked(dispatched_at) orunconfirmed; elsegrading_mode: none⇒none; not frozen ⇒pending; no frozen run ⇒none/no_frozen_run; archived ⇒skipped/archived; suspended ⇒skipped/protection_suspended; elsepending(due, not yet asked). The ledger wins over the row's state. A test per status indetail.db.test.ts. - Migration
0061_project_staff_writes: the three additive columns.detail.tsexportsrepoScores(project, repo, runs)(exactlyProjectRepoScores, the score route's answer) andslotRuns(db, repos), the one computation behind the rows and the score's write;deadline.tsexportsrepoForUpdate(the project then the repository locked) andliveRepoForUpdate(the same, refused unlessisLive), the one lock every repository write takes. - Not done here: the web page (M3-12); the notification (M3-09); the student's own resend (M3-09); storing a repository's name without its owner (the M3-04 follow-up, still open); a manual re-dispatch of an unconfirmed review.
M3-09 — split (orchestrator, 2026-10-04)¶
Three tasks, from the spec: M3-09a the student's side as it reads (the view, the cards, the home, the student's resend, the leak test); M3-09b the notifications (the seven kinds, the Teams manifest bump, the day-before reminder, the sends at the trigger sites); M3-09c the real-time notices of F-PROJ-21, done client-side. The "From …" notes of the former M3-09 card are kept below, under the task that inherits each.
M3-09a — The student's project view, the cards, the student's resend¶
- Depends on: M3-04, M3-08b, D18. ‖ M3-12b.
- Goal: the project's student view (THE single exit, N-SEC-20), the
student's project cards on the home and the classroom page through
KINDS, the student's own resend of a pending invitation, the leak test. - Decisions (orchestrator, 2026-10-04, from the spec; the product owner's D18/D26/F-PROJ-15 stand):
- The home shows projects too (F-ORG-14: Activities is the summary
of every activity):
StudentHome.open/upcoming/pastbecome theStudentActivityCardunion throughKINDS; the web renders a project card minimally (title, status, deadline), the fullProjectRowbeing M3-13's;StudentClassroomPagelists projects in Activities and has no Projects tab (F-ORG-15):hasProjectsdropped. - Card status words stay the spec's (
to_accept | in_progress | locked, plusreleased); the card gainsinvitation,githubLinked,repoUrl(own repository only) — the three facts of the row's four-state button,docs/merge/05-web.md§5.3: not linked, linked without a repository, invitation pending, ready — anddeadlineAtis the student's effective deadline. (Review round 1: areleased: booleanbesidestatus: "released"was dropped.) - The view
GET /app/api/student/projects/:idis loaded through the classroom's student branch (the rule ofreadableClassroom, the student payload forced): a teacher in the student view sees no repository and no Accept; an impersonation gets the student payload and stays read-only. Between the provisional and the definitive freeze the score shows as frozen, marked indicative until the release. - The student's resend reuses
resendInvitationand its minute column, never the staff's route. - From M3-02 (product owner, D26 addendum 2026-10-02): teachers create
projects before the cutover, so the students saw nothing until this
task:
projectActivity.studentCardsstayed empty until the view landed with its N-SEC-20 leak test. - From M3-03 (orchestrator, 2026-10-02): the student's resend of an invitation (F-PROJ-07).
- From M3-08b (2026-10-04): the student's own resend of an invitation
(F-PROJ-07) reuses the staff's rule and column (
resendInvitation,invitation.ts;project_repos.invitation_resent_at: one minute per repository, shared by whoever asks) through the student view, never the staff's route. - As delivered (branch
merge/M3-09a-student-project-view). What M3-13, M3-09b, M3-09c and M5-03 inherit: - Contracts (
packages/contracts/src/project.ts,student.ts):StudentProjectCard{ kind: "project", id, title, classroomId, classroomName, courseCode, startAt, deadlineAt (EFFECTIVE), status: StudentProjectStatus (to_accept | in_progress | locked | released), invitation: "pending" | "accepted" | null, githubLinked, repoFullName, repoUrl }— the repository named only when live (provisioned, not deleted).StudentProject= the card's facts without the three repository fields +{ gradingMode, repo: StudentProjectRepo | null, release: StudentProjectRelease | null, serverNow };StudentProjectRepo{ fullName, url, invitation: "pending" | "accepted", deleted, locked, lastCommit: { sha, at } | null, ciStatus, run: { sha, url, conclusion, completedAt } | null, score: { points, max, grade, frozen } | null }— the score INDICATIVE until the release, null under gradingnone; once the student's deadline is applied or passed,lastCommitandciStatusare the SELECTED run's commit and conclusion (or null /none), never the row's head, which the webhooks keep moving on a repository left open (strategycommit) (N-SEC-20, review round 1).StudentProjectRelease{ at, points, max, grade, comment }, the comment AS THE RELEASE WROTE IT: new columnproject_repos.released_comment(migration0063_project_released_comment, additive), written byreleaseProjectbeside the points and the maximum — a comment written after the release waits for the next one, like the score it may describe.StudentHomemoved fromlive.tstostudent.tsasStudentActivities.extend({ serverNow });StudentClassroomPagelosthasProjects. - Pure rules (
@quiz/domain/projectStudent.ts, unit-tested):studentProjectStatus(released → locked at the effective deadline or while GitHub holds the repository locked → in progress once provisioned → to accept),studentProjectGroup(upcoming before the start, past once locked or released, open between),studentScoreRun(the frozen slot once the deadline is applied — never a run after it —, the current slot before; never the review's nor the teacher's). - The view (
Q:modules/project/studentView.ts, the one exit):studentProjectCards(db, userId, now, classroomId?)(every classroom where the caller holds a claimed seat, or one; the repository joined through a STUDENT seat only, ADR-018; never a draft nor an archived project; the soonest project deadline first),studentProject(db, scope, userId, now),studentResendInvitation(409 repo_unavailablebefore Accept, thenresendInvitation's refusals). LoaderstudentProjectView/findStudentProjectView(guards.ts): the project (neither draft nor archived) thenfindReadableClassroomwithstudentViewforced — ONE rule with the classroom page — the course's staff, a claimed seat, an impersonation through the seat; aseb/kiosksession, a stranger get the 404; an API token reads (as it reads the classroom page) and never writes.ReadableClassroom.seatgainedstaff. The resend route takesstudentProject(a claimed student seat, own portal session). A confined session's home (/student/home) carries no project card (StudentScope.confined,activity/kind.ts): its exam leads nowhere else. - Routes (
project/routes.ts, with the App only):GET /app/api/student/projects/:id→StudentProject;POST /app/api/student/projects/:id/invite→ProjectInvitationResent, auditedproject_repo.invite_resentwith the student as actor. - The home
GET /app/api/student/homeis theactivitymodule's (activity/routes.ts,studentHomeinactivity/service.ts):ActivityKind.studentCards(db, caller, now, { classroomId?, confined })— one signature for the home and the classroom page; the groups are the domain'sStudentActivityGroup.live.studentHomenow answers anEvaluationHome(evaluation cards only), read by the evaluation adapter and the kiosk'spairableEvaluations.htmlUrl(fullName)(github/git.ts) is the one GitHub page URL. - Web (minimal, the full row is M3-13's):
student/cards.tsxgainsActivityCard(one switch over the union for Open now, Upcoming and Past; a project drawsActivityRowwith its title, the status wordsproj.status.*, the deadline or the start, no button) andUpcomingByDaygroups byopensAt(card)(a project'sstartAt);mostUrgentranks a project by its deadline. Mock:?projects=1gives the student persona three cards (STUDENT_PROJECT_OPEN,_SOON,_PASTinmock/student.ts, grouped throughstudentProjectGroup), each served as a view derived from its card (one scored, one released) and checked bycontract.test.ts; M3-13 fleshes them out. - Leak test (
studentView.db.test.ts, N-SEC-20): a second student's repository, login, id, head sha, score, review score, teacher score and comment, the source and distribution names, a draft's title, the staff's flags (toVerify,multiple,malformed,protectionSuspended), the first student's own push and run after the deadline (sha and points), their own unreleased review score, their own teacher score and comment before the release, a comment written after the release — searched for in every response of the first student (home, classroom page, view, Accept, resend), for each caller (student, teacher in the student view, impersonation, API token), before and after the release; the second student's hint never on a topic the first listens to; a confined session's home without a project card. - Not done here: the notifications (M3-09b), the F-PROJ-21 notices
(M3-09c), the
ProjectRowbutton and its indicative score (M3-13), a group's repository (M3-15).
M3-09b — Notifications of projects¶
- Depends on: M3-09a, M3-04, M3-05a, M3-05b, M3-08b.
- Goal: the seven notification kinds of F-NOTIF-13 with ADR-030 defaults and templates (en/fr), one Teams manifest bump, the day-before reminder, and the sends at the trigger sites.
- The kinds and their defaults (F-NOTIF-13):
project_published(the students of the classroom, on publish —publishProjectsends none today, scheduled publication included),project_repo_invited(the student, after a successful Accept),project_provision_failed(the course's staff, for aproject.accept_failedwhosepayload.notifyis true — the row's first failure, never a refused invitation; the text must cover a name collision,repo_name_taken, which only staff can resolve),project_deadline_reminder(the student, the day before THEIR effective deadline),project_deadline_applied(the staff, one entry per project with the count locked, worded from the auditproject.deadline_enforced—locked,archived,committed),project_grade_final(the students, on the FIRST release only:ProjectReleaseResult.firstand the audit'spayload.first; the place is marked in the release route; a release again notifies nobody — product owner, decision 3 of M3-08b),github_org_lost(the staff, on GitHub's delete events only — an installation or an organization gone — never on a transient failure). The staff's notice of a review asked (heig-classroom'sllm_review_dispatched, "(n/N repositories)") is worded from the auditsproject.review_dispatchedandproject.checkpoint_dispatched(dispatched,deleted,unconfirmed,failed; at most one per pass that changed something);project_repo.review_skippedis the staff's. - The reminder mirrors F-NOTIF-06's: claimed by a conditional UPDATE
and sent in the same task, never claimed without being sent, in the
ticker's
projectTick(modules/project/jobs.ts). M3-05a re-armsprojects.reminder_sent_aton every move of the project's deadline; a student whose repository has its own deadline is reminded of THAT one, so the claim is per repository for them: aproject_reposreminder column beside the project's. - Dropped: heig-classroom's
grade.finalmail on the final review (sent whenreview_grade_run_idis filled). F-PROJ-15: the final score reaches a student only after the release;project_grade_finalcovers it. A student's own dispatch is never worth a notice. - From M3-04 (2026-10-02): the hints exist (kind
projects, to the repository's students'user:and the course'scourse:, neverclassroom:;Q:modules/project/events.ts, tested). - From M3-13 (2026-10-04): a student's entry about a project links to
/projects/:id— one address, which serves the student's project page to a student and the staff's page to the staff (App.tsx, F-ORG-15). - Tests: each kind sent once at its trigger, to its audience and nobody
else (a student never receives another student's notification); the
reminder claimed and sent together, per repository where a repository has
its own deadline;
project_grade_finalon the first release only. - Decisions (orchestrator, 2026-10-04, within D18 and F-NOTIF-13):
seven kinds and no eighth — the staff's "review dispatched" notice is
NOT a notification (the project page shows the dispatch state);
project_publishedandproject_deadline_appliedoff by e-mail and Teams, the five others on; the reminder claimed per project and per repository with its own deadline, re-armed only by a move more than 24 h ahead;project_deadline_appliedonly when a pass locked or committed (locked + committed > 0), folded per project (a project fold target, F-NOTIF-12 amended);project_provision_failedwith areason;github_org_loston the row's actual transition, one notice per organization per loss, per classroom; the project kinds hidden from the settings without the App. - As delivered (branch
merge/M3-09b-project-notifications): - Contracts (
packages/contracts/src/notifications.ts): the seven kinds inNOTIFICATION_KINDS(the student kinds afterresults_updated, the staff kinds aftergrading_ready), their payloads ({ projectId, projectTitle }, pluscountfor the two folded staff kinds,reason: PROVISION_FAILURE_REASONS = repo_name_taken | github_errorfor the failure;github_org_lost{ classroomId, classroomName, orgLogin }),DEFAULT_CHANNEL_ENABLED,NOTIFICATION_AUDIENCE(seat/course),GITHUB_KINDSandnotificationKindsFor({ …, github })— the settings route passesgithubApp(config) !== null, so the grid (unchanged) lists them only where the App exists. - Schema (migration
0068_project_notifications, additive):notifications.project_id(FK cascade, index) lifted from the payload bynotifyMany;NOTIFICATION_FOLD_TARGETSgainsproject_deadline_appliedandproject_provision_failed→projectId(two partial unique indexes);project_repos.reminder_sent_at;github_organizations.suspended_at(review round 1). - Pure rule (
@quiz/domain/deadlineReminder.ts, unit-tested):DEADLINE_REMINDER_MS(the evaluations' reminder reads it too) andreminderClaimAfterMove(re-armed only when the new deadline is more than a day away). The window rule is the scans' (start_at). - Shared helpers (review round 1):
notifyUsers(db, userIds, payload, log?)innotifications/service.ts— one payload to an audience, deduped, best-effort — used by the project kinds,orgLostand the evaluations'sendDeadlineReminders;classroomStaffIds(db, classroomId)inorg/service.ts, used by the project's staff kinds,orgLostandgrading/events.ts'sstaffOf;repoUserIds(db, repos)over many repositories in one query. - Templates (
notifications/templates.ts, en/fr, Teams activity strings included;reason.*sentences);notificationPath:/projects/:id,/classrooms/:id/settingsforgithub_org_lost; the Teams topic takesprojectTitle.teamsApp.ts:TEAMS_ACTIVITY_KINDS+ 7,TEAMS_APP_VERSION2.1.0 → 2.2.0. - Emitters (
Q:modules/project/notify.ts:classroomStudentIds,announcePublished,tellProjectStaff,remindDeadlines; best-effort, after commit):publishProject(by hand and the ticker) →project_published;acceptProject→project_repo_invitedto whom THIS request invited and GitHub left pending — the accepting student of a repository just provisioned, the members a group's first Accept invites, a later member joining (tellInvited; rebased over M3-15b-1) —,project_provision_failedto the staff on the row's first failure (auditproject.accept_failedgainspayload.reason:repo_name_taken|invitation_refused|github_error);releaseProject→project_grade_finalto the students of the repositories the release covered,firstonly;runDeadlineJob→project_deadline_appliedwithlocked + committedwhen > 0;projectTickstep 4bremindDeadlines(the project claim — group members of a repository with its own deadline left out too —, then the repository claim,LIVE, no staff lock, both understart_at <= deadline − 24 h);projectDeadlineMovedandsetRepoDeadlineapply the re-arm rule;github/service.tsorgLostfromforgetInstallationandmarkOrgDeleted(when the row held an installation): the course staff of each non-archived linked classroom, one entry per classroom. A suspended installation is kept (suspended_at, auditedgithub_org.installation_suspended) and acts as none (installed(): installed, not suspended, active — the listing, the connect sheet, the projects and the journal read the same rule); GitHub's state decides, never the webhook's action, and a deletion after a suspension is told once. - Web:
NotificationPanelwords the seven kinds (notif.project*,notif.githubOrgLost,notif.provisionReason.*) and opens{ view: "project" }/{ view: "classroomSettings" };settings.kind.<kind>[.desc]×7 inen.tsandfr.ts; the mock's settings passgithub: true. - Tests:
project/notifications.db.test.ts(each kind to its audience and nobody else, the fold, the reason, the reminder's two claims and its rules, first release only, deadline applied only when something locked);github/webhooks.db.test.tsandgithub.db.test.ts(org lost via webhook and healing, told once per classroom, never on a suspension);templates.test.ts,teamsApp.test.ts(seven kinds at 2.2.0), the contracts' and the domain's unit tests, the web panel and settings tests. - Review round 1 (2026-10-04): the suspension model above; the
settle-at-start claim dropped for the scans'
start_atrule (an own deadline given within a day on a project open for longer IS reminded: the student's window is their time in the project); the shared helpers;GITHUB_KINDSaconstarray. - Not done here: the F-PROJ-21 notices (M3-09c); a notice when
retireLoginHoldersretires an installed row (a rename race is the likelier reading: documented, not sent).
M3-09c — Real-time notices of projects (F-PROJ-21)¶
- Depends on: M3-09a, M3-12a.
- Goal: the worded notices of F-PROJ-21 on the project page and the
student's project — a push received, protected files restored, a score
captured, an acceptance, a deadline applied, a review dispatched, a sync
done — client-side, by comparing the payload re-read on a
projectshint with the one before it (no data-carrying SSE frame: the hints stay what ADR-005 makes them). Worded throught()from the structured difference, never a server sentence. - From M3-04 (2026-10-02): the facts the notices read are recorded —
a push (the receipt and
lastCommit), a restore (auditproject_repo.restore{files}), a score captured (aproject_grade_runsrowok), the suspension (auditproject_repo.revert_cap), a repository deleted (auditproject_repo.deleted). - From M3-09b (2026-10-04): the notices are NOT the notifications —
project_deadline_appliedreaches the staff's bell once per job pass, and the student's reminder their bell; a notice worded from the re-read payload must not repeat them. The facts a notice may read now includeproject_repos.reminder_sent_at(not shown to the student: the reminder itself is). - Tests: web, on the re-read payloads: each notice from its difference, nothing on an unchanged payload.
- Decisions (spec challenge and product owner, 2026-10-05): the notices
are ephemeral toasts on TWO pages — the staff's project page and the
student's project — computed in the browser from the page's data before
and after each re-read (the 30 s refetch, the
projectshint), worded throught()en/fr, shown through the existing toast primitive. No server work, no new field, no notification behind them, no per-kind preference in v1 (heig-classroom'snotifyPrefsis not ported). Staff: one notice per kind per re-read, with a count ("3 pushes received"), never one per repository. Dropped: "protected files restored" (no datum on the page; theprotectionSuspendedflag and the bell cover the cap) and "sync done" (M3-07 shows its last-sync line and counts itself). No deadline notice for the staff and no release notice for the students:project_deadline_appliedandproject_grade_finalalready toast through the notification kinds (notifications/toasts.ts), never doubled. Nothing on the first read (theToastGaterule), nothing on an unchanged read. F-PROJ-21 amended. - As delivered (branch
merge/M3-09c-project-notices): one hook,useNoticeToasts(query, notices)(Q:apps/web/src/notifications/notices.ts), generic over a page's data: the first read after mount is the baseline (cached data is not a read, nor is a failed refetch), a new key a new baseline, the existing toast primitive keyed per kind. Two pure functions it is given:project/projectNotices.ts(the four staff kinds, counted per kind) andstudent/studentProjectNotices.ts(the four student kinds overStudentProjectalone; a push and a score only while the project is open on the payload'sserverNow). Keysproject.notice.*(.onefor a single one) andsproj.notice.*;?notices=1mock scene. Decisions taken alone: an acceptance is a repository that appeared (heig-classroom'sassignment_accepted), not an invitation accepted on GitHub; the student's "acceptance" is their invitation accepted only; the lock is the onewarning-toned notice; the read after a staff write of the page echoes nothing, because no compared field is one such a write changes (said and tested inprojectNotices.ts).
M3-10 — Web: projects in Activities, "New ▾"¶
- Depends on: M3-01 contracts, M1-05. ‖ M3-11.
- Goal: the classroom's Activities tab and
/activitieslist projects; "New ▾" (Evaluation, Poll, Project) with the GitHub gate (opens M2-07's sheet first). - As delivered (branch
merge/M3-10-web-projects; product owner 2026-10-02): projects are open to every teacher, no gate but GitHub's — and no door to a page that does not parse:projectandprojectNewstaypreview(CLASSROOM_PAGES) until M3-12 and M3-11, and a screen asksrouteEnabled(view)(router.ts) before linking to one. - New ▾ (
Q:apps/web/src/activities/NewActivity.tsx, stateless:ClassroomViewhands it its GitHub read) replaces the header's "New evaluation" on the Evaluations tab: Evaluation and Project. No Poll: the launcher takes no classroom (its audience is a remembered choice), so a Poll item could not land on this classroom. The plain "New evaluation" button stays until the GitHub read is a success (loading, failed, or the 404 of a platform without the App) and whereverprojectNewdoes not parse — so production keeps the plain button until M3-11. Not connected ⇒ Project carriesproject.notConnectedand callsClassroomView'sopenConnect(/classrooms/:id/settings?connect=1, M2-07's sheet; no return to a form, F-GH-02); connected ⇒ routeprojectNew(/classrooms/:id/projects/new,ComingSoon, nosection). - Projects group (
Q:apps/web/src/project/ProjectGroup.tsx) underEvaluationList, the tab unrenamed, the only one of the two with a heading (the tab names the evaluations):GET /classrooms/:id/projects(ProjectActivitySummary[], keyclassroomProjectsKeyunderclassroom); title +StateBadge, Start, Deadline. Drawn only with rows: nothing while loading, on[]or on a 404;QueryErrorotherwise. A row opensprojectonly where it parses (not clickable in production until M3-12). KIND(activities/model.ts): one entry perActivitySummarykind, the client mirror of the server'sActivityKind— type, type label, anchor (a project's start), closes (its deadline), live (a project never), in the room, gantt span, home (nullwhere the page does not parse), state label and tone;kindOf(row)is the one cast. Views, the gantt, the menu and the Projects group read it; no view branches onkind(butActivityMenu's narrowing to readmode). A published project is bucketopen(amber badge and bar, the legend now "waiting room, paused, open project"), a locked oneended.project/common.ts: the state's tone and word, pure.- /activities lists the union and still creates nothing; a type chip is drawn only for a type that has rows (every chip, one rule); no row menu for a project.
projecttakes no tail:/projects/:id/groupsis home in production.- Mock:
mock/project.ts,?projects=1(three on PRG1-2026, one per state; r2 is the unconnected classroom), checked incontract.test.ts. Scenesclassroom-projects,classroom-new-menu[-unconnected],activities-projects[-schedule]. Students see no project (M3-13).
M3-11 — Web: new project form¶
- Depends on: M3-02 contracts, M2-07. ‖ M3-10, M3-12.
- Goal: redesign of
AssignmentForm(sheet or stepped page, decided in the PR's challenge step), novice/expert split (spec 08). - From M3-02: the refusal bodies have no response schema yet: add
ProjectRefusal({ error: ProjectErrorCode, message, branches? }) andProjectUnassigned(students) tocontracts/src/project.tswith this form, their first consumer. - From M3-10: "New ▾ › Project" on a connected classroom navigates to
the route
projectNew(/classrooms/:id/projects/new,router.ts), which rendersComingSoon(App.tsxPAGES) and is stillpreview: droppingpreviewfromprojectNewturns "New ▾" on in production (NewActivityasksrouteEnabled("projectNew")). Replace that placeholder with the form — or, if the form is a sheet of the classroom page, drop the route and haveNewActivity's Project item open the sheet instead. On create, invalidateclassroomProjectsKey(id)andactivitiesKey. - Decided for it (product owner and orchestrator, 2026-10-02): one
page at the
projectNewroute, not a sheet nor steps, one primary action Create (no Publish: F-PROJ-13 gives it to the project page);projectNewstayspreviewuntil M3-12 (the form lands on the project page, stillComingSoon). - As delivered (branch
merge/M3-11-project-form): Q:apps/web/src/project/NewProjectPage.tsx(page, queries, create),ProjectAdvanced.tsx("Advanced options"),newProject.ts(pure: the draft,projectBody— parsed byProjectCreateitself —, the missing fields and their ids,refusalPlace),ProtectedFiles.tsx(the suggestions as checkboxes and thegrading.ymlwarning, for M3-12 too). A field's error is the sharedfieldErrorProps/<FieldError>ofsrc/ui/(the evaluation's timing step uses them too).- Novice (spec 08): name, source (
GET …/sources, as sorted by the API; its default branch and last push under it), deadline (datetime-local, the browser's zone —localTimeZone,ui/dates— named when it is notSCHOOL_TIME_ZONE). Picking a source readsGET …/sources/:repo; itssuggestedProtectedare sent even while Advanced stays folded (a Create pressed while it is read waits for it). - Advanced (folded, not remembered): branches (chips in the order
chosen, the first the students' default; default the source's default
branch), history (
squash/whole), publication (by hand / at the start), deadline as a date or a duration (by hand only; in days), at the deadline (lock/commit), grace, score (auto/none) and scale (linear / score is the grade, its fallback said as static text), protected files (the source's suggestions only; uncheckinggrading.ymlwarns), groups and their advisory maximum. A start only with a scheduled publication, a duration only with a manual one: the form never sends both. - Refusals (
ProjectRefusaladded tocontracts/src/project.ts;ProjectUnassignedwaits for Publish, M3-12):source_not_foundunder the source (withbranches: names them and unfolds Advanced; the sources and detail are re-read),deadline_pastunder the deadline,duplicate_slugunder the name,not_connected/app_not_installed(from the sources' read or the create) a page state whose action is the route{ view: "classroomSettings", id, connect: true }(newconnecton that route,?connect=1),distribution_faileda danger alert above the form with Retry, values kept; anything else "Could not create the project". While the POST runs, the whole form is a disabled fieldset and Create reads "Building the students' repository…". - Success: invalidate
classroomProjectsKey(id)andactivitiesKey, toast, navigate toproject(M3-12). KeysprojectSourcesKey,projectSourceKey(beside the projects, not under them). - Mock
mock/projectNew.ts(section 8b, after GitHub whose link it reads): sources and details under?projects=1,?srcmissing=1,?distfail=1, a 2.5 s build; the dispatcher now awaits a handler. Scenesproject-new,-advanced,-refusal,-unconnected. - Not done: checkpoints (M3-05), the project page (M3-12).
M3-12 — split (orchestrator, 2026-10-04)¶
M3-12 (the project page) is split in three PRs: M3-12a the page on what
main holds today (M3-08a's reads, M3-05a's repository routes, M3-05b's
checkpoints, M3-02's lifecycle), M3-12b the per-repository writes of
M3-08b (score, resend, re-enable) and the review field, M3-12c the
release. The original card's notes stay here; each part has its card below.
M3-12a — Web: project page (reads, lifecycle, deadline, checkpoints)¶
- Depends on: M3-08a contracts (M3-08b for the writes). ‖ M3-11.
- From M3-08a (2026-10-02): the page reads
GET /app/api/projects/:id(ProjectDetail: the summary,counts,primaryActiondecided by the server — draw that one action, never derive it —,rowswithrepo: nullas "not accepted", the flags, the scores with theirsourceandgrade.fellBackfor the scale's warning) and, when a row opens,GET …/repos/:rid/runs(GradeRunList; the three slot ids mark the current, frozen and review runs).liveStaletrue: refetch once a few seconds on (the live state fills the cache meanwhile). Card M3-08a, "As delivered". - Goal: redesign of
AssignmentDetailin sections; grade history and override inside. Scenesproject,-grades,-sync-banner. - From M3-10: the route
projectrendersComingSoonand is stillpreview; droppingpreviewfromprojectmakes the project rows clickable in production (the Projects group and /activities, throughKIND.project.homeinactivities/model.ts, which asksrouteEnabled("project")). - From M3-11 (product owner, 2026-10-02): the new project form is
shipped but
projectNewstayspreviewso production never lands on aComingSoonafter a create. DroppreviewfromprojectNewandprojecttogether in this task: that turns "New ▾" on in production (NewActivity), the form, and the page it navigates to after a create ({ view: "project", id }). Publish is this page's: its409 unassigned_studentsbody (students: enrollment id, nom, prenom) gets its response schema here,ProjectUnassignedbeside M3-11'sProjectRefusal. The protected files' edit reusesproject/ProtectedFiles.tsx. - Decided for it (product owner and orchestrator, 2026-10-04): (1)
projects go live in production with this PR —
previewdropped fromprojectANDprojectNew(New ▾, the form and the page);projectGroupsstayspreview; (2) an action is drawn only when its route exists: Release and Sync are said in the header as text, never derived, until M3-12c and M3-07 turn them into the primary button;none⇒ no accent; (3) group mode hidden in the new project form until M3-16, the 409unassigned_studentsstill lists the names (no link); (4) a deadline moved later after it passed asks for confirmation, naming the reopen and the repositories it reaches; (5) refetch every 30 s while the tab is visible, once ~3 s after aliveStale: trueresponse (SSE with M3-09); (6) the edits are the fields F-PROJ-03 allows once published (name, deadline, deadline strategy, protected files), the full draft edit a follow-up; (7) the final review state per repository waits for M3-08b'sreviewfield (M3-12b). - As delivered (branch
merge/M3-12a-project-page). What M3-12b, M3-12c, M3-07, M3-09, M3-13 and M3-16 inherit: - Routes (
router.ts):projectandprojectNeware no longerpreview— New ▾ › Project, the form and/projects/:idship in every build;projectGroupsalone stays behindCLASSROOM_PAGES.App.tsxrendersproject/ProjectPage.tsx;soon.projectis gone. - The page (
project/ProjectPage.tsx; keysprojectKey(id),projectRunsKey(id, rid),projectCheckpointsKey(id)under a rootprojectthatHINT_ROOTS.projectsnow names):GET /app/api/projects/ :id(ProjectDetail), refetched every 30 s while visible and once 3 s afterliveStale: true(projectRefetchInterval,projectPage.ts). States: skeleton, a 404 as the classroom's (QueryErrortitledproject.notFound),PageErrorwith retry, the empty roster (EmptyState→ the classroom's Roster tab). Header: name renamed in place (EditableTitle,PATCH {name}) wheneditablenames it, the state badge (project/common.ts), the archived badge, ONE sentence (projectStatus, the key and the date it names: draft / scheduled / open until / locked since / locked not yet frozen / released on / release and sync as text) and the counts line; the primary button only forprimaryAction: "publish"(POST …/publish, success →projectKey,classroomProjectsKey,activitiesKeyinvalidated); overflow menu Archive (confirmation) / Restore / Delete (typeToConfirmthe name, the body says nothing is deleted on GitHub, F-PROJ-16; then the classroom). Publish's409 unassigned_studentsis parsed by a LOCAL zod schemaUnassignedBody(projectPage.ts) and shown as a danger alert listing the names, no link — M3-08b addsProjectUnassignedto the contracts; M3-12b replaces the local schema by it. Any grade withfellBack⇒ one warning alert on the scale. - Settings (
project/ProjectSettings.tsx, onePATCHper change, the detail invalidated — PATCH answersProjectSummary, a subset): the deadline (DateFieldofevaluation/TimingStep.tsx, now exported and taking adescription; written on blur;422 deadline_pastunder the field, the stored value back; a reopen —isReopen,reopenedRepos: the project'sdeadlineAppliedAt, or a live repository applied and following the project's deadline — asks first throughuseConfirm; a manual draft by duration sendsdurationMinutes: nullwith the date), the deadline strategy (Segmented), the protected files (ProtectedFiles.tsx,suggestedwidened toreadonly string[]: the source'ssuggestedProtectedread fromGET …/sources/:repo∪ the files protected today). Each disabled whereeditabledoes not name it, every control while the project is archived. The facts set at creation (source, distribution, branches, history, publication, grace, score, scale) as a read-onlydl. The full draft edit (start, grace, grading, scale, publication mode) is a follow-up. - Repositories (
project/ProjectRepos.tsx): onetrperrowsentry,repo: null"not accepted" (a seat not claimed said), a row withoutenrollmentIdtagged "left the roster"; columns Student, Repository (link, provisioning / invitation state), Last commit (sha, relative time, live commit count), CI, Score (final points/max,Grade, source), Deadline (effective, "own deadline" tag), State (the lock — by staff —, the freeze, the flags ofrepoFlags: deleted, several GRADE annotations (red), protected files in conflict, to verify, modified after publication (amber), malformed score, locked by archiving / no protection ruleset (zinc)). A phone keeps Student, Score and State, the flags folded into "n flags" (@2xlcontainer query). A deleted repository is a muted row.liveStale⇒ "Refreshing GitHub's state…" on the heading. A row with a repository opens the sheet; 100 rows are a plain table. - The sheet (
project/RepoSheet.tsx, read from the page's own data): invitation, last commit, CI (+ live checks), the five scores, the deadline and lock — own deadlinePUT …/repos/:rid/deadline(set, or "Follow the project's" = null;422 deadline_pastunder the field),POST …/repos/:rid/{lock,unlock}— each answer laid over the row (setQueryData) then the page invalidated;actionable(not provisioned, deleted, or archived project ⇒ disabled, said why;409 repo_unavailabletoasted); the runs (GET …/repos/:rid/runs): kind, branch@sha, conclusion, points, tests, parse status and detail, the slot marks current / frozen / review, after the deadline, to verify. - Checkpoints (
project/ProjectCheckpoints.tsx, gradedautoonly): list with status sent (with its time) / scheduled / void (checkpointStatusonisVoidCheckpoint); add in aFormDialog(name, days before the deadline →offsetDays: -n, or a date); delete after a confirmation (none offered once dispatched;409 checkpoint_dispatchedtoasted);422 due_past/due_after_deadlineand409 duplicate_checkpointworded in the dialog. - Refusals worded by
refusalMessage(error, t)(projectPage.ts, onrefusalKeyandapi.ts's newrefusalCodeOf, whichrefusedWithnow reads too):not_draft,distribution_missing,deadline_past,strategy_frozen,publish_mode_frozen,repo_unavailable, the four checkpoint codes; the rest show the server's message, elseerror.save. - Shared pieces:
project/parts.tsx(RepoLink,Points,Score,CiBadge,SCORE_SOURCE_KEY),project/RunHistory.tsx(the sheet's run table, GitHub'sconclusionworded throughproject.run.conclusion.*, an unknown one shown raw),Factinui/page.tsx(a<dt>/<dd>pair). One key per word:project.review(a run's kind, its slot, a score's source),project.col.ci,project.frozen,project.flag.toVerify,project.flag.multiple,project.deadline,results.col.student,question.publish. - Form (
ProjectAdvanced.tsx): group mode hidden (GROUPS_OFFERED = false) until M3-16. - Mock (
mock/project.ts,?projects=1): the three seeded projects now carry a page — the published one's rows walk every state (pending provisioning, invitation pending, CI fail, to verify + conflict, multiple, malformed, deleted, own deadline, staff lock, degraded, a review run, a student off the roster), the locked one is frozen, released with one score changed since (Release said) and onscore_is_gradeover scores out of 100 (the scale warning); every write above, the checkpoints (one void on the locked project), the first read of a projectliveStale: true;?unassigned=1refuses Publish with three names.addMockProjecttakes the createdProjectSummary, fullyeditable. Checked bycontract.test.ts(ProjectDetail,GradeRunList,ReviewCheckpoint). Scenesproject,project-sheet,project-draft,project-unassigned,project-locked(--width=390for the phone). - Tests:
project/projectPage.test.ts(rules),project/ProjectPage.test.tsx(states, primary action, publish 409, archive / delete, edits and reopen, flags, sheet, checkpoints, refetch, French), fixturestest/project-fixtures.ts; router tests updated (projectandprojectNewparse everywhere). - Not done here: see M3-12b and M3-12c; the final review state per
repository (
reviewfield, M3-08b); the "students cannot accept yet" line of the empty repositories hint (project.repos.noneAccepted) goes with M3-13.
M3-12b — Web: per-repository writes and the review state¶
- Depends on: M3-12a, M3-08b.
- Goal: in the row's sheet (
project/RepoSheet.tsx): the teacher's score with its comment (PATCH …/repos/:rid/score,ScoreOverride;409 not_frozenandgrading_noneworded), the resend of a pending invitation (409 otherwise, 429 once a minute), the re-enable of the protection (clears "protected files in conflict"); the final review state per repository from M3-08b'sreviewfield (asked at / of sha, not confirmed, no review — degraded when archived as its lock or protection suspended) in the table's State column and the sheet. ReplaceUnassignedBody(project/projectPage.ts) by the contracts'ProjectUnassigned. Sceneproject-grades(the sheet with a teacher score). - From M3-12a: the sheet reads its row from
projectKey(id)and lays each repository answer over it (settleinRepoSheet.tsx); the refusals go throughrefusalMessage; the mock'sMockRepocarriesteacherandprotectionSuspendedalready. - As delivered (branch
merge/M3-12bc-project-writes, ONE PR with M3-12c). What M3-13, M3-16, M5-03 and M3-07 inherit: - The sheet (
project/RepoSheet.tsx):settlenow takes a patch of the row ((repo) => repo), so every answer — a deadline state, the scores, an invitation status, a cleared flag — is laid over the row the same way, then the page invalidated.- The teacher's score (
project/TeacherScoreForm.tsx:ScoreSection— the five slots, the review's state and the form —,PATCH …/repos/:rid/score,ScoreOverride): points, a maximum field shown only when the server says the score is held to none —scores.scoreMaxon every row (review round 1: the API'steacherRunMax,detail.ts, ONE rule for the write ofgrades.tsand the page; the scored run's maximum, null for pass / fail only, malformed, several annotations, or a run to verify; tested ingrades.db.test.tsanddetail.db.test.ts) —, otherwise "out of N" as text and nomaxsent; a comment (sent trimmed, always, so it can be emptied); Save (common.save), the sheet's ONE primary; Clear ({ points: null }) once a score exists.scoreOverrideBodybuilds the raw object and keeps whatScoreOverride.safeParseaccepts (invariant 7); locally it only knows "no points" and "own maximum required". The answer (ProjectRepoScores) patchesscores,releasedandflags.changedAfterRelease. The form exists only whenteacherScoreBlockis null: on anoneproject the line says it is not graded, before the definitive freeze that the score waits for it — so409 not_frozen/grading_noneare met only by a stale page, and worded under the form with the three 422s (FormErrorwithrefusalMessage). The Teacher fact of the five slots shows the points with their maximum (scores.teacher.max). - Resend (
POST …/repos/:rid/invite): asecondarysmbutton beside the Invitation fact, only whileinvitationStatusispendingand the repository isactionable;invitationStatusof the answer laid over the row (anacceptedanswer removes the button and says the student already has access);429 resend_too_soon,409 invitation_not_pending,502 invite_failedand409 github_account_staleworded (toast). - Re-enable (
POST …/repos/:rid/protection): asecondarysmbutton under the flags, only whileflags.protectionSuspended, with a 13 px line beside it saying the restores resume at the next push and the runs marked to verify stay so; no confirmation (nothing is lost, nothing restored at once). The answer clearsflags.protectionSuspendedon the row (the table's "protected files in conflict" tag goes, "to verify" stays), the run list is invalidated, the toast repeats that past runs stay to verify. - The final review (
row.review,ProjectRepoReview): ONE table,reviewView(projectPage.ts) →{ key, tone, detail }per status, read by the row's tag and the sheet's detail line alike — "review pending" / "review asked" / "no review" zinc, "review done" green, the degraded "no review" (archived as its lock, protection suspended) amber, "review not confirmed" red;detailis the line that says why or what next (asked on {date} of commit {sha}; not confirmed: never sent again, set the score by hand; no frozen run; archived; protection suspended: re-enable it; not frozen yet), null for done and a plain none. The sheet draws it as a "Final review" fact inside the scores grid. The table's State column drawsreviewTag: the same tag, except that a repository not frozen (trivially pending) and anonewithout reason (a project gradednone: no row has a review) get no tag — decided here, to keep a row of an open project free of a tag that says nothing.
- The teacher's score (
UnassignedBodyis gone:unassignedStudentsparses the contracts'ProjectUnassigned(messagerequired,enrollmentIda uuid — the mock's?unassigned=1refusal mints uuids by rank, since its roster ids are not).- Refusals (
REFUSAL_KEY, widened to the resend'sgithub_account_staleofPROJECT_ACCEPT_REFUSALS):not_frozen,grading_none,score_max_required,score_max_mismatch,score_above_max,invitation_not_pending,resend_too_soon,invite_failed,github_account_stale; the release'sto_verifyis worded byreleaseRefusalalone (M3-12c), with the names. - Contracts (own block, review round 1):
ProjectRepoView.scores.scoreMax(above) andProjectReleaseRefusal(M3-12c);contract.test.tshas a describe calling the four write routes of the mock and checkingProjectRepoScores,ProjectReleaseResult,ProjectReleaseRefusal,ProjectInvitationResent(and the 429) andProjectRepoProtection. - Mock (
mock/project.ts), over #498's repair (which gave the mockreview,teacher.maxandunverified, and the fixturesfinal.toVerify):MockRepo.dispatch(the ledger row, so a review can be asked or not confirmed) andresentAt; onereviewderivation,reviewOf, feedingreviewStateof@quiz/domainwith that ledger. The locked project's rows now walk the review states (done;i === 2not confirmed; archived degraded; the gone student's done) and the to-verify repository (i === 4, rather thani === 3: a restored head, its review asked but never filling the slot) carries the teacher's score that settles it (72 out of the teacher's own 100,scoreMaxnull), so the server'sscoresFinalholds and Release is offered; without it the release is refusedto_verifynaming that repository. Repository ids are uuids (repoId), asProjectReleaseRefusal.reposwants them (review round 2). The four routes:PATCH …/score(teacherScoreMaxapplied, the 409s and 422s),POST …/release(counts innot_frozen, ids into_verify, the snapshots),POST …/protection,POST …/invite(once a minute,429on a second click). Sceneproject-grades(the sheet of the teacher-scored repository);contract.test.tschecksProjectDetailwithreview. - Tests:
projectPage.test.ts(review words and tones, the table's tag rule,scoredRunMax,teacherScoreBlock,scoreOverrideBody, the refusal keys,releaseRefusal,ProjectUnassignedstrictness),ProjectPage.test.tsx(the score with and without a scored run, the 422s and 409s worded, the answer laid over the row, Clear, no form onnone; resend, 429, accepted; re-enable clearing the tag; the review per status in table and sheet; French). - Not done here: a manual re-dispatch of an unconfirmed review (M3-08b left it open); the student's own resend (M3-09).
M3-12c — Web: the release¶
- Depends on: M3-12a, M3-08b.
- Goal:
primaryAction: "release"becomes the header's primary button (POST …/release;409 not_frozen/grading_noneworded; a re-release rewrites the snapshot, said as such), the sentenceproject.status.releasekept as its description; the "modified after publication" flag then offers the re-release. Scenesproject-releaseand-released. - From M3-12a:
ProjectPage.tsxdraws the button forpublishonly (actionsof itsPageHeader); add thereleasebranch there, and the status sentence stays (projectStatus). - As delivered (branch
merge/M3-12bc-project-writes, ONE PR with M3-12b): - The button (
ProjectPage.tsx):primaryAction: "release"draws the header's one primary, "Release scores" (Send), or "Release again" oncereleasedAtis set (the server namesreleaseagain only when a final score moved after the release,changedAfterRelease).useConfirmfirst: the first release says the final score of each of the {n} live repositories becomes the student's and the gradebook's, and that a student's score is indicative until then; the release again says the snapshot is rewritten with today's final scores, the "modified after publication" marks go, nobody is notified again. ThenPOST …/release; onProjectReleaseResultthe page, the classroom's lists and the Activities are invalidated and a toast says "{scored} of {repos} repositories have one". Sync stays a sentence (M3-07). - The sentence (
projectStatus):project.status.releasekept for the first release; a newproject.status.rerelease, dated at the release, once released and offered again — the previous sentence said "ready to be released" of a project already released. - Refusals (
releaseRefusal,projectPage.ts), as a dangerAlertunder the header titled "The scores were not released", kept until a release succeeds, parsed with the contracts'ProjectReleaseRefusal(review round 1: a discriminated union besideProjectUnassigned—not_frozenwithlive/frozen,to_verifywithrepos: uuid[], the two bodiesgrades.tsthrows; the mock's release route builds its refusals through it): "{frozen} of {live} live repositories are frozen for good"; the students named from the page's rows by thereposids (a generic "some repositories" when none is on the page);409 grading_noneand anything else throughrefusalMessage. An alert rather than a toast because the to-verify names must stay readable while the teacher opens each sheet. - Mock:
POST …/releaseabove;?unreleased=1makes the locked project not released yet (Release its one action). Scenesproject-release(the confirmation open) andproject-released(the header after it);project-lockedunchanged shows the release again. - Tests (
ProjectPage.test.tsx): the button, the confirmation declined then accepted, the POST, the toast and the refetch; the release again and its wording; nothing offered once released with no change; the three refusals worded; French.projectPage.test.ts:releaseRefusal, therereleasesentence.
M3-13 — Web: student ProjectRow¶
- Depends on: M3-09a contracts, M2-07.
- Goal: the four-state onboarding of §5.3 on the student home and the classroom page, and the student's project page.
- From M3-09a (2026-10-04): the cards arrive in the
StudentActivityCardunion on both pages already;student/cards.tsxdraws a project throughActivityCardas a plainActivityRow(title,sproj.status.*, the deadline or the start, NO button) — replace that branch byProjectRow. The card carries what the four states need:githubLinked(false ⇒ "Link my GitHub account"),repoUrlnull ⇒ "Create my repository" (POST /app/api/student/projects/:id/accept, its refusalsProjectRefusal),invitation: "pending"⇒ the link to accept on GitHub and the resend (POST /app/api/student/projects/:id/invite,429 resend_too_soonworded),"accepted"⇒ "Open repository";statusthe badge word;startAtin the future ⇒ no Accept yet. The page readsGET /app/api/student/projects/:id(StudentProject): the indicative score (repo.score, say "indicative";frozen⇒ "frozen at the deadline"), the evaluated commit and its run (repo.run), the release block oncereleaseis set (the final score, its grade, the teacher's comment). Mock:?projects=1, idsSTUDENT_PROJECT_OPEN,_SOON,_PASTofmock/student.ts. - From M3-09b (2026-10-04): every student notification of a project
(
project_published,project_deadline_reminder,project_repo_invited,project_grade_final) opens/projects/:id— the bell's{ view: "project", id }and the e-mail's link alike — so the route MUST serve a student the student's view (GET /app/api/student/projects/:id), with the invitation link and the resend on it: a student landing there from "Your repository is ready" expects the button, not the staff's 404. - Scenes:
-unlinked,-accept,-invitation-pending,-ready,-released; the defaultstudent-homeunchanged. - Decisions (orchestrator, 2026-10-04, from the spec and the
precedents): one address,
/projects/:id, dispatched by role as/classrooms/:idis (F-ORG-15, ADR-018); ONE action per row by state; a teacher in the student view and an impersonation see no action, one muted line; the ready repository is a secondary external link, never the accent;"student"added to theprojectshint roots; M3-12a's "students cannot accept yet" hint dropped. - As delivered (branch
merge/M3-13-student-project-row, web only). What M3-09b, M3-09c, M3-15 and M5-03 inherit: - The row (
student/ProjectRow.tsx, rules instudent/projectRow.ts, pure and unit-tested):projectActionKind(facts, now)names the one action of a project —link(no GitHub account, open or not yet started: "Link GitHub", the account link flow ofgithub/api.tswithreturn= the current page),accept(linked, published, start passed, deadline not passed, no repository: "Accept"),notStarted(linked, before the start: nothing),invitation(repository with a pending invitation: "Open the invitation" →<repoUrl>/invitations, a new tab),open("Open repository", a secondary link, locked or not),notAccepted(locked or released without a repository),deleted(a card in progress that names no repository is one GitHub lost,factsOfCard; the page readsrepo.deleted; nothing is made again).ProjectFacts.repois a variant: live (url, invitation), deleted, or null. ONE wording per state (STATE_KEY,sproj.state.*andsproj.invitation.pending), on the row's line after status · deadline and in the page's repository card alike;github_not_linkedis worded with the same key. Upcoming keeps no button (DESIGN.md, "Coming up").ACCENT_KINDS= link, accept, invitation:primarylights those only;mostUrgent(StudentClassroom.tsx) ranks a project by its deadline only whileneedsStudentAction, so a ready repository never takes the classroom page's accent (decided here: the invitation counts as a step to take, so it may wear the accent). The title is the door to the project's page (RowLinkonActivityRow: a real/projects/:idhref,navigateon a plain click;ActivityCardandUpcomingByDaytakenavigate).ActivityRowmoved tostudent/ActivityRow.tsx(re-exported bycards.tsx) with the two countdown lines every row shares,leftLineandstartsLine(timeLeftLineandupcomingLineofcards.tsxare written on them);RowActiongainedhref+externalfor the links. - The clock is the server's (invariant 5): the home, the classroom's
Activities and the project page read
useServerNow(serverNow)(realtime/useServerClock.ts: the payload'sserverNowsampled intouseServerClock,useNow(30_000) + clock.offset) — the offset is state, so a sample that moves it re-renders at once; every countdown, the start gate of Accept, the "Evaluated commit" label andmostUrgentjudge on it, never on the browser's clock alone. - Accept (
useProjectAction):POST /app/api/student/projects/:id/accept; the button says "Creating your repository… (up to a minute)" while it waits; success toasts what to do next (byinvitationStatus);provision_in_progressandinvitation_not_pendingre-read instead of resending (REREAD_REFUSALS, aprogresstoast);github_account_staleturns the button into "Relink GitHub"; every other refusal is worded (studentRefusalMessage,sproj.refusal.*:app_not_installed,distribution_missing,repo_name_takenall say "ask your teacher";provision_failedsays try again). The wording mechanism is ONE,wordedRefusal(error, table, t)inapi.ts: the project page (refusalMessage) and the student's row each keep only their table. Every write settles by invalidating thestudentroot (studentRootKey), so the home, the classroom page and the project page re-read. - Read-only readers (
useStudentReadOnly): a teacher in the student view (useStudentView) or a session of kindimpersonation(useMe) get no button and no Resend, the linesproj.readOnly. - The page (
student/StudentProjectPage.tsx,studentProjectKey(id)under thestudentroot): header with the classroom as parent (named once), the status badge, the course code and the deadline ("Due …", plus "· … left" only while time remains; the start while it is ahead), the one action (primary while a step is to take, secondary for a ready repository, none read-only); "My repository" (the name as a GitHub link, the invitation state with the student's Resend —429 resend_too_soonworded,invitation_not_pendingre-read,repo_unavailable,invite_failed—, the lock, the last commit — the EVALUATED commit once the deadline is applied or passed — withproject/parts.tsx'sCiBadge, translated: GitHub's raw conclusion is never printed); "Score" before the release: the current score as "Indicative score" or the frozen one as "Score at the deadline", both with an "indicative until your teacher publishes" hint, the grade (results.col.grade) with anindicativehint, the run line ("See the run on GitHub", its time; the sha is in the commit line already); nothing at all under gradingnone; "No score yet" without a graded run; "Result" once released: final score, grade, "Published on", the teacher's comment (feedback.comment) in an outlinedNotePanel. States: skeleton, 404 ("does not exist, or no seat", back to the activities), error with retry (activities.kind.projectas its title). Phone: the action full width under the title —PageHeadernow gives its actions row the whole width undersmwhenever that row is ONE control with no help and no menu (desktop unchanged). 49sproj.*keys, en + fr. - Routing:
projectisstudentSafewithbottomSlot: "courses"(itssectionstays the teacher's Activities);App.tsxgives the staffProjectPageand everyone elseStudentProjectPage.realtime/hints.ts:projects→classroom,project,student. - Renamed: M3-12a's
project.repos.noneAccepted(en/fr, "… The students' side of projects is not open yet") isproject.repos.none, "No repository yet: a row fills once its student accepts the project." (project/ProjectRepos.tsx, one line). - Mock (
mock/student.ts,?projects=1): seven cards, one per state (STUDENT_PROJECT_OPEN|SOON|PAST|ACCEPT|INVITED|LOCKED|DELETED;STUDENT_PROJECT_IDSis whatcontract.test.tsparses the view for), the repository's name derived from the title's head (studentRepoName);?unlinked=1unlinks the persona; the accept route provisions the card for the page's life (?provisioning=1: twenty seconds;?refused=1:409 repo_name_taken;?stale=1:409 github_account_stale, plain flags ofmock/runtime.ts); the invite route keeps the minute.contract.test.tsalso POSTs Accept (ProjectAcceptance, a refusal ofPROJECT_ACCEPT_REFUSALS,?refused=1throughflags) and the Resend (ProjectInvitationResent, then 429). Scenesstudent-home-projects[-unlinked],student-classroom-projects,student-view-classroom-projects,student-project[-accept|-unlinked|-invited|-deleted|-released|-provisioning|-relink]. - Tests:
student/projectRow.test.ts(the kind per state, the card's deleted inference, every refusal worded),student/ProjectRow.test.tsx(the title link, the action per state, the accent, read-only readers, the Accept flow: wait, re-read, relink, each refusal),student/StudentProjectPage.test.tsx(states, facts, indicative and frozen wording, the server's clock over the browser's, no raw conclusion, release block, Resend and its refusals, read-only, French),hints.test.ts,router.test.ts,bottomNavSlots.test.ts,StudentClassroom.test.tsx(mostUrgentwith projects, the rows),StudentHome.test.tsx; the Grades page's project row (M3-01) renders unchanged with the merged contracts. - Not done here: the notifications (M3-09b: an entry about a project
links to
/projects/:id, which now serves the student), the F-PROJ-21 notices (M3-09c), a group's repository (M3-15), clone commands.
M3-14 — Pilot and load test¶
- Depends on: M3-01…13, M2-06.
- Goal: on staging with the staging App: walk classroom's user stories end to end; 100 repositories at a deadline applied in < 5 min; tick duration measured with 100 due projects.
- Output: a short report in the PR; findings as tasks.
- As run (2026-10-06): the product owner runs the pilot alone in
PRODUCTION on the test organization
heig-quiz-classroom, classroomTestCourse-A— the staging App is deferred. First walk: connect the organization, link and unlink the journal, link one's own GitHub account from Settings, create a draft project "Labo PGCD". The load test (100 repositories at a deadline, the tick with 100 due projects) is still to do.
Findings as tasks (first walk, 2026-10-06)¶
| # | Finding | Task | Outcome |
|---|---|---|---|
| 1 | "Install the App on GitHub" opens a new tab; GitHub's setup return then loads a SECOND Quiz in it while the first still waits | M3-14b | The install is a same-tab round trip, like the account link: the setup return lands on /classrooms/<state>/settings?connect=1&installed=<orgId>, the sheet reopens with the new organization picked; the wait-for-refetch machinery goes. The App's settings: Setup URL https://<host>/setup/github/installed, "Redirect on update" ticked |
| 2 | The Free-plan check should lead to where an organization becomes an Education one | M3-14b | A link to https://education.github.com/globalcampus/teacher (GitHub's Education FAQ: "Upgrade your academic organizations"; an unverified teacher is offered to apply there) |
| 3 | The ANTHROPIC_API_KEY organization-secret check |
M3-14f | NOT NOW (product owner): removed progressively once Quiz's own LLM does the final review. Done 2026-10-14 (product owner): removed entirely, card M3-14f |
| 4 | A student should be led to link GitHub when a project is published or opened | M3-14d | Decided (product owner, 2026-10-06): the project_published notification's text only, for a recipient with no linked GitHub account; no redirect, no banner. Before the decision: F-GH-05 and 05-web.md §5.3 say no banner, no nudge (product owner, 2026-10-01). Link GitHub is already the one primary action of an unlinked student's open project card and page (projectActionKind), with the return to the page; the pilot saw the READ-ONLY student view (finding 8), where no button shows. At most: a project_published notification worded for an unlinked recipient |
| 5 | "New evaluation" is a dialog, "New project" a page: one scrollable modal for both? | M3-14e | Won't do (product owner, 2026-10-06): New project stays a page. The reasons: M3-11 decided one page (product owner and orchestrator, 2026-10-02); DESIGN.md keeps a centered dialog to three fields and a scrolling dialog to READING. The project needs everything before Create (the source, its branches and strategy are fixed; the build takes seconds). If an overlay is wanted, the conforming one is a Sheet, the route projectNew removed |
| 6 | The Projects table should link the GitHub repositories, and say the time left | M3-14a | A row menu with the source and the students' (distribution) repository, staff only (F-PROJ-02); the deadline cell of a published project says the time left (no live countdown) |
| 7 | A draft shows a start date although nothing is fixed before Publish | M3-14a | The server sends no start for a manual draft (its stored start is its creation) and no deadline for a manual draft counted in a duration; the table shows "—", the Activities timeline treats it as undated |
| 8 | A teacher cannot test a project as a student (heig-classroom let a teacher's seat accept) | M3-14c | ADR-077 PROPOSED: the staff seat of "Join as student" (ADR-018) accepts an individual project; its repository is badged and counted nowhere. ADR-077 accepted (product owner, 2026-10-06): v1 without group projects nor reset; an owner's GitHub account documented as not reproducing the rulesets. Q7 → M3-14g; Q8 → the M8-01 dry run counts imported staff-seat repositories. Today, without code: a second personal account with the student role on the roster, its GitHub account not a member of the organization |
| — | ADR-077 Q7: leaving the course staff keeps one's staff seats | M3-14g | Follow-up (product owner, 2026-10-06: keep as is for now) |
Findings as tasks (second walk, 2026-10-06)¶
Project "Labo PGCD" published in TestCourse-A, accepted from the
teacher's staff seat (ADR-077), then a commit pushed to its repository.
| # | Finding | Task | Outcome |
|---|---|---|---|
| 9 | The Projects table hides both repositories behind "…" | M3-14h | Two icon links on the right of the row — "Source repository", "Distribution repository" (their tooltip and accessible name), a new tab each, never opening the row — on every view that lists a project (the classroom's Projects table, the Activities table, cards and week); IconLink in ui/ (DESIGN.md › Icon button). The distribution's place stays empty while it is being built. A project row has no menu any more |
| 10 | The student's card says only the title, "in progress · N days left" and Open repository; heig-classroom's row said the state of the work | M3-14i | Once the repository is ready, the card says the short sha and date of the commit the view stands on, the number of commits, the CI status and the indicative score, read as the student view reads them (studentCiReading, studentScoreRun; F-PROJ-04 amended). The count: the commits pushed to the student's repository (their group's in a group project) by no bot, from the push receipts the intake already writes — a new push_receipts.commits, the payload's commits that are distinct and that no bot authored (the App, a workflow; a sync pull request merged brings no commit of the App's), summed over the receipts of no bot's push received by the effective deadline (studentCommitCount, the freeze's receivedLate). No GitHub call: the live cache would cost two requests per repository per minute on a page every student opens (100 repositories ≈ 12 000 an hour, over the 5 000 the organization shares, N-PERF-07) and counts the App's commits and the pushes after the deadline (N-SEC-20). Limits: a force push that rewrites history counts the rewritten commits again; a receipt from before the column counts one; a repository imported from heig-classroom (M8-01) counts from the import. Not delivered: the CI checks ring (passed / total) — the check runs are the live cache's, any commit's, the App's included — the lock state beyond the status word, and "full review running" (the review is the staff's until the release, F-PROJ-15). After the release the card shows no indicative score (the page shows the final one) |
| 11 | The staff page said "0 students · 0 accepted · 0 of 0 frozen" and "No student in the roster — Add students" with only the teacher's seat | M3-14j | Not a bug: the empty state shows only while the page has no row at all; a staff seat is a row once it holds a test repository (projectDetail, ADR-077 §2), so after the teacher's Accept the table shows that one row, badged "Teacher", and the counts stay at 0 (counted nowhere). The screenshot predates the Accept, or the production build predated M3-14c. A regression test now draws a roster of no student with the test row |
| 12 | After the push the student's page still said "Last commit: no commit yet" | — | To check on GitHub (the App's settings › Advanced › Recent deliveries). The last commit is set by the push webhook only (the daily reconcile.repos catches a lost one up, default branch only): the App must be subscribed to Push (with Workflow runs, Member, Repository, Pull request, Organization); the delivery must reach /webhooks/github with a valid signature (GITHUB_WEBHOOK_SECRET) and answer 200; the repository row must be live and known by GitHub's id (provisioned by Accept); the ref a branch, not a deletion; the sender a person — neither <slug>[bot] nor github-actions[bot]; the github.webhook job then runs (a failure is stored on webhook_deliveries.error and retried). A staff seat's repository is NOT skipped: the intake's tracker and the handler find a repository by GitHub's id, whoever holds it. The page re-reads on the student's hint; a screenshot taken before the delivery was processed shows no commit |
| 12 (outcome) | The pilot's push | — | Arrived (product owner, 2026-10-06): the card shows f63d962, 1 commit. Nothing to fix |
| — | The student's project page adds little over the card | M3-14l | Decided (product owner, 2026-10-06): rebuild it (finding 14 below) |
Findings as tasks (third look, 2026-10-06)¶
The product owner compared the student's card and page with heig-classroom's
row (C:../web/src/StudentHome.tsx: a status badge with its icon, a
calendar, a commit icon, a lock on a frozen score, the GitHub mark on the
button) and with Quiz's best pages (an evaluation's Results). The cause
found: the project was poured into the evaluation card's mould
(ActivityRow, whose line is ONE string joined with " · ", so it cannot
carry a badge nor an icon, and DESIGN.md's "Status is a badge" was broken),
a staff table part reused in prose (CiBadge's "—"), and no composition in
DESIGN.md for a student's activity card or its page, so each task
improvised. The staff page counted "2 commits" (GitHub's, the App's first
commit included) where the student's card said "1 commit".
| # | Finding | Task | Outcome |
|---|---|---|---|
| 13 | The student's card is plain text: status as words, no icon, a stray "—", no GitHub mark | M3-14l | Decided (product owner, 2026-10-06): on EVERY student card (evaluation, poll, project) the status is a badge beside the title; the kind badge becomes a kind icon before the title (named by a tooltip and the accessible name); the accent is the primary button's ALONE (the kind badge carried it too); the line becomes icon + text items (MetaItem, ui/). A project's: the deadline (calendar), the commit (short sha, date, N commits), the CI badge only once a run exists, the indicative score (a lock once frozen, "indicative" in words); the action wears the GitHub mark. DESIGN.md gains "The student's activity card" |
| 14 | The student's project page: no way back, another width, a weaker card | M3-14l | Decided: rebuilt (B2): the student classroom page's width; a breadcrumb "Courses › classroom › project" (a new Breadcrumb in ui/); the card's items as its header; then the repository (invitation, Resend, lock), the CI run, the score, and after the release the final score, grade and comment. No commit history (no server data without a GitHub call, F-PROJ-13): a later card if wanted |
| 15 | The breadcrumb, app-wide | follow-up | Decided: the breadcrumb replaces ParentLink in the whole app, staff included, in a PR after M3-14l (which makes the primitive) |
| 16 | Staff "2 commits", student "1 commit" for one repository | M3-14m | Decided: the staff cell shows the student's count (studentCommitCount, the same receipts), GitHub's total in its tooltip |
| 17 | The teacher's test repository says "no protection ruleset" | M3-14k | Provisioned while heig-quiz-classroom was on GitHub Free (403 plan restriction, H8); the organization moved to Team afterwards (product owner): nothing applies hgc-protect later. Decided: the daily reconcile.repos retries it on a live repository with no ruleset, not archived, before its effective deadline; a delay of up to a day is accepted, no staff action |
| 18 | A negative CI score | M3-14n | Decided: -2/6 counts 0/6 (F-PROJ-10 amended; it was malformed) and the staff see that it was clamped; a maximum of 0 or below stays malformed |
| — | #567's defaults | M3-14i | Confirmed (product owner, 2026-10-06): commits counted, not pushes; no indicative score on the card after the release; the commit's own date; links instead of "…" in every list |
| — | The load test | M3-14 | Later (product owner, 2026-10-06) |
M3-14f — Drop the ANTHROPIC_API_KEY organization-secret check¶
- Depends on: the final review moved to Quiz's own LLM.
- Note (pilot finding 3): NOT NOW (product owner, 2026-10-06): removed progressively once Quiz's own LLM does the final review.
- Note (2026-10-12, ADR-106
§10): Quiz's own LLM does it for a project in the
criteriamode (cards L-02a…g). The check, and the CI'sllm-reviewtier, go course by course, once that course's projects run incriteriamode; a project inautomode keeps both until then. - As delivered (PR #879, 2026-10-14): the check dropped outright on the
product owner's decision of 2026-10-14 (Quiz reviews with its own LLM). Pending:
the owner removes the App's
organization_secretspermission on GitHub.
M3-15 — Groups (API)¶
Redesigned by ADR-070 (product owner, 2026-10-04): classroom group sets, a project's copy that follows its set until the deadline. Split in two PRs.
M3-15a — Group sets and a project's copy (database only)¶
- Depends on: M3-02.
- Create:
Q:modules/group/{routes,service}.ts,Q:db/group.ts(group_sets,student_groups,student_group_members), the migration (alsoprojects.group_set_id,project_groups.source_group_id, drop ofprojects.group_max_size),packages/contracts/src/group.ts, the pure rules in@quiz/domain(formRandomGroups: balanced sizes,smaller|larger;groupSyncPlan: the diff of a set and a copy, with the follow predicate of ADR-070 §4), thegroup_set.*andgroup.*audit actions. - Routes (staff,
staffAccess): sets (list, create, rename, duplicate, delete with409 set_in_use), groups (create, rename, delete), a member's move or removal, the random formation; read-only on an archived classroom (409 classroom_archived). A project'sgroupSetIdin the draft's PATCH (not_draftonce published);409 no_group_setat publication; the copy made, replaced, and kept in step in the set's transaction where no repository is touched (none exists before M3-15b). - Port from:
C:modules/assignments/groups.ts(names, slugs, positions, moves), itsgroups.db.testwhere the rules survive. - Tests: the follow predicate and the random formation in the domain; the routes, the copy's step with the set, a stopped copy, staff seats never placed, the 404 of a classroom not reached.
- As delivered (branch
merge/M3-15a-group-sets): migration0064_group_sets(Q:db/group.ts;projects.group_set_idset null,projects.groups_stopped_at,project_groups.source_group_idset null,projects.group_max_sizedropped);@quiz/domaingroupSets.ts(groupSizes,formRandomGroupson an injectedrandomInt,copyFollows,groupSyncPlan, the default names in the creator's language); contractsgroup.ts(GROUP_REFUSALS:classroom_archived,set_in_use,duplicate_name,nobody_to_place,size_out_of_range) and, inproject.ts,groupSetIdon create, patch and summary,no_group_set,unknown_group_set; hint kindgroups(course topics only).Q:modules/group/{routes,service,errors,events}.ts, registered without the App; loadersaccessibleGroupSetandprojectsClassroom(guards.ts); every write of a set goes throughwriteSet(classroom FOR SHARE, set FOR UPDATE, the following projects FOR UPDATE in id order, the write, the copies stepped, the audit). The copy is the project module's (Q:modules/project/groupCopy.ts:followingCopies,stepCopies,replaceGroupCopy); a project's patch locks the set it names FOR SHARE before its own row. The stop is stored (orchestrator D1):groups_stopped_atwritten with the deadline applied (ticker) or the archive, never cleared, so neither a reopen nor an unarchive makes a copy follow again; the per-repository stop is M3-15b's. Every route answers the set (GroupSetDetail) but the set's deletion (204). The plan applies the deletions first, then the renames through a temporary name (a swap of two names), the new groups, the moves. The web form lostgroupMaxSize(and its i18n keys);GROUPS_OFFEREDstays false until M3-16.
M3-15b — Group repositories and their membership on GitHub¶
Split in two PRs (orchestrator, 2026-10-05). The product owner's decisions
of 2026-10-05 (P1: what "nothing to revoke" means; P2: the first deadline
stops a group) are ADR-070's amendment and F-PROJ-06/F-PROJ-17's wording.
- From M3-03 (orchestrator, 2026-10-02): Accept answers 409 no_group
for any group project: this task replaces it by the group's repository,
and covers the revocation on leaving the roster (F-PROJ-17) for the
INDIVIDUAL repositories M3-03 makes too. (revoke_failed is a roster
refusal, has_repo a group one: Accept answers neither.)
M3-15b-1 — Group repositories at Accept, the invited accounts, synchronous revocations¶
- Depends on: M3-15a, M3-03.
- Port from:
C:group-repos.ts(claimGroupRepo, the name and its disambiguation,inviteMembers,revokeMember),inviteOnGithubLink,group-repos.db.testwhere its rules survive. - Goal: Accept of a group project (the copy's group,
no_groupwithout one; the first member provisions, every linked member invited, a later one joins); every invited account recorded; a student linking GitHub invited; whose repository through the copy only (N-SEC-20); the synchronous revocation before a roster removal, an unclaim, an e-mail change, a self-enroll;409 has_repofor a set's write that would reach a group with a repository until M3-15b-2. - As delivered (branch
merge/M3-15b1-group-repos): migration0067_project_repo_access(Q:db/project.ts: one row per (repository, roster line, GitHub account), the login invited,revoked_at; the rows go with their line). Its backfill records the individual repositories M3-03 provisioned from the student's link of TODAY and their student seat: a student who had unlinked before the migration gets no row, and their removal is auditednot_invited(no fallback by the collaborators GitHub lists: documented, not built).Q:modules/project/groupRepos.ts:seatRepos(one batched reader,pickStudentRepoover a seat's own repository and its copy group's — a heig-classroom live individual repository keeps its student),seatRepoits single case,repoMembers(a repository's readers: its student's seat, or its copy group's members, never the creator for having created it), read by the student view and cards, the staff page, Accept, the hints, the resend and the invitation on link.Q:modules/project/ accept.ts: a group project's Accept — the copy group or409 no_group, re-read under the project's row FOR SHARE when the group's row is inserted (a set's write steps the copy under FOR UPDATE: R3); a provisioned row is joined (the member invited, the answer THEIR invitation); otherwise the claim (two Accepts make one row,provision_in_progress), the name<project slug>-<group slug>(the group's id appended when another tracked row bears it), the allow-list of M3-03 unchanged (repo_name_taken), then every other member with a link invited, best effort, read after the provisioning; a student's own repository whose access a roster change revoked is joined again.Q:modules/project/access.ts:recordGrant(written BEFORE GitHub is asked, the line FOR SHARE and still claimed by the account's user as a student seat — otherwise nobody is invited),releaseLine(the other side of that lock: the line FOR UPDATE,RevokeFailedwhile an account recorded since the revocation is live),inviteAccount(the one invitation: today's login, the grant, thepushinvitation, the grant taken back on a refusal, and the invitation taken back when the grant was revoked while GitHub was asked; auditedproject_group.repo_invite),inviteOnGithubLink(after a link, best effort),revokeEnrollmentAccess(installation clients first — an installation GitHub no longer knows, 404/403 on its token, isapp_not_installed; a first provisioning under way (its claim fresh) isRevokeFailed, retried once it is done; a failed one's, or one whose claim went stale, found by its id; each recorded account marked revoked BEFORE GitHub is asked (live again if GitHub refuses), so that an invitation's re-check either finds it live — its invitation landed before the revocation listed them, and is cancelled — or takes itself back; then each by its immutable id's login of today, its pending invitation (pendingInvitees) cancelled BEFORE its seat; a repository left with no live account hasinvitation_statusnone; the P1 skipsapp_not_installed,repo_deleted,not_provisioned,account_gone, thennot_invited(the repositories the line reads byseatReposwith no account of it recorded) once every account is out, each auditedproject_group.repo_revoke). ONE guard for the four roster writes that take a line or its account away,afterRevocationin theorgservice:removeEnrollment,unclaimEnrollment,updateEnrollment(every e-mail change, afterduplicate_emailis ruled out: the line may have been claimed since it was loaded) andselfEnroll(a student line turned staff seat, afteralready_enrolledis ruled out) revoke first, then write in a transaction behindreleaseLine;RevokeFailedis worded502 revoke_failed, every refusal throughrosterRefusal(Q:modules/org/errors.ts,ROSTER_REFUSALSin contractsorg.ts). An invitation whose grant was revoked while GitHub was asked is taken back; if that fails, the grant is live again for the next revocation and an error is logged. The resend: an individual repository's student seat (none:409 repo_unavailable); a group's members still out (an invitation pending on GitHub, or no account recorded); a student's own, them alone; audited{ logins, invitationStatus }.Q:modules/project/groupCopy.tswithplanReachesRepoGroup(@quiz/domain): a copy group with a repository row — from the first Accept's claim on, unless it failed before GitHub made the repository — has its slug fixed and a step that would delete it, or take a member out of it or bring one in, is refused for EVERY project concerned,409 has_repowithGroupRefusalProjects(alsoset_in_use's details); a staff seat leaving it is no GitHub change.acceptRefusal(@quiz/domain) no longer saysno_group;revokeCollaboratorcancels invitations first. Web:revoke_failedandalready_enrolledworded (error.githubRevokeFailed,error.alreadyEnrolled);no_grouphas no Accept screen yet (M3-13);has_repois M3-16's to word. For M3-16b: a group repository no roster student reads any more is listed under its creator's account (enrollmentIdnull), andcounts.acceptedcounts repositories, hence groups, in group mode. Known gaps: the reservation of a name while a provisioning is in flight (heig-classroom wrote it on the row) is not ported — two classrooms provisioning the same name at the same second answer the secondrepo_name_takenuntil the first is done; the M8-01 import must check heig-classroom's individual repositories inside group assignments (lot-1 leftovers) againstisLiveIndividualRepo. - Tests:
groupRepos.db.test.ts(32): the first Accept and its invitations and grants, a later member, two at once,no_group, the name disambiguated, an invitation on link; the creator moved out (no row, no student view or card, no hint, no resend); the staff's resend of the members still out, a member's own;has_repo(out, in, deletion; nothing written) and a rename (slug fixed, kept from another group); a removal (the invitation cancelled before the seat),502 revoke_failed(roster, set, copy, unclaim and e-mail change unchanged), the P1 skips (a token refused too), a relinked account, an unclaim and an e-mail change, a self-enroll, an individual repository (its state cleared), a resend with no student (repo_unavailable); the races interleaved through the fake GitHub (a line removed before or while it is invited; an account recorded during a removal, an unclaim, a self-enroll; a removal and a set's move during the first provisioning, a stale one; an invitation landing between a revocation's listing and its seat's removal; a move out during an Accept), a classroom deleted with its grants, the backfill statement on an M3-03 row. Domain:planReachesRepoGroup.
M3-15b-2 — The follow on GitHub: group.sync, the per-group stop, confirmations¶
Split in two PRs (orchestrator, 2026-10-05); the product owner's PO1–PO2 are ADR-070's second amendment of 2026-10-05.
M3-15b-2a — Per-group stop, the group.sync job, needs_confirmation, access to revoke¶
- Depends on: M3-15b-1.
- Goal: the per-group stop (the first of the project's deadline and the
repository's own); the
group.syncjob (one lease per project, the stops re-read in its transaction; a departure waits for GitHub, an arrival is invited) replacing M3-15b-1's409 has_repofor a member's move;409 needs_confirmationwith the consequences and their digest; the access to revoke flag. - As delivered (branch
merge/M3-15b2a-group-sync): migration0069_group_sync(projects.group_sync_due_at,group_sync_job_at,group_sync_failures, a partial index on the due mark;project_groups.stopped_at, backfilled from the first ofgroups_stopped_at, the repository'sdeadline_applied_atand the audit log's firstproject_repo.deadline_applied(a reopened repository keeps its group stopped);project_repo_access.revoking_at;project_group_members.departing_at).@quiz/domain:groupSyncPlanper group (a stopped group is never deleted, renamed nor moved, its name and slug reserved; a move with one stopped end is held),splitPlan(what the set's transaction applies, what waits for the job, the consequenceslose/join, the groups with a repository a plan would delete) andconsequenceDelta;planReachesRepoGroupremoved. A grant is revoked only once GitHub confirmed it (review, orchestrator):revoking_atis set before GitHub is asked,revoked_atafter its answer (only while still marked), a refusal clears the mark; every revocation (the roster's and the job's) takes the grants not revoked, marked ones included (a crash's are asked again);releaseLineandcompleteDepartureshareassertNoLiveGrant(revoked_atnull), so a roster write during the job's GitHub call is502 revoke_failed; an invitation's re-check takes itself back when its grant is revoking or revoked, and a failed take-back on a group repository leaves the grant live and the project due. A stray grant (STRAY_GRANT, one SQL predicate: not revoked, on a group repository whose line is not a non-departing member of its group) is the job's work and the page'saccessToRevoke(a pending departure, a refused revocation, a failed take-back alike).Q:modules/project/ groupCopy.ts:copiesBefore(read under the set's lock before the write) andstepCopies(the delta of consequences, its SHA-256 digest over the sorted (project, group, line, kind),ConfirmationNeededunlessconfirmis it; the rest applied, the departures markeddeparting_at, the project due),stopProjects(groups_stopped_atand every group: tick step 2, the archive),stopGroups(tick step 3 for a group whose repository's deadline is applied — the projects concerned locked FOR UPDATE first, in id order, with those whose own deadline is due; a stop drops the group's pending departures), and the job's steps under its locks (set FOR SHARE, project FOR UPDATE, the member row FOR UPDATE):syncSteps(departures, arrivals, stray grants),beginDeparture(only while the student departs from a group not stopped, the grants marked in the same transaction; otherwise held, nothing touched),completeDeparture(refused while an account of the line there is not revoked; then decided from the plan, the group left counted as following: in step — kept and invited again; a place into a following group — moved; else left, e.g. the project stopped after the revocation),completeArrival(from the plan),beginStrayRevocation,settleSync(the due mark cleared, kept, or moved later:FAILED_RETRY_MSdoubling up to an hour).Q:modules/project/groupSync.ts:runGroupSyncJob(its own frame onheldLease, since it must run without the App: P1),requestGroupSync(sent after a member's place commits), ticker step 7, queuegroup.sync(jobs.ts); without a queue nothing sends it and the moves wait.access.ts:recordGrant(given theRepoRow) also locks the line's member row FOR SHARE and answersgonefor a non-member,departingfor a departing one (Accept answers409 provision_in_progress, the link and the resends skip them);revokeDeparture(one repository, the grants the job marked,via: "group.sync",not_invitedwhen none was ever recorded, whatever the provisioning — the roster'snot_invitedtoo);revocationClient; a refusal is logged and retried with the backoff.repoMembersleaves out departing members. Contracts:confirm?onGroupMemberPut,GroupConsequence(s),needs_confirmationinGROUP_REFUSALS,accessToRevokeonProjectRepoView; the web wordsneeds_confirmationwith a placeholder until M3-16b's dialog. Audit: the set's writes namepayload.deferred;repo_revokeandrepo_invitetakevia: "group.sync".has_repostays for deleting a set group a following copy holds with a repository. Hints after a pass: the course's staff and the moved students'user:topics. - Tests:
groupSync.db.test.ts(20): the consequences and their digest, a wrong and a stale digest, writes that add none (unrelated, a move back); a move between two following groups (revocation before invitation, audits), an arrival, the P1 skips, a refused revocation (flag, backoff, retry), two projects on one set, a crashed lease taken over, a roster removal while a move waits; Accept, link and resends of a departing student refused; the stop between the revocation and the copy write, the student put back meanwhile, an account recorded since the revocation; a roster removal during the job's refused revocation (502), a crash after the mark asked again, a stray access flagged and retried; a repository's earlier deadline stopping its group for good, a departure whose group stopped before its revocation began (held, nothing revoked), the backfill (the audit log's first application).groupRepos.db.test.ts:needs_confirmationreplaceshas_repofor moves; a confirmed move during the first provisioning is never invited. Domain: the stopped group,splitPlan,consequenceDelta. The shared world isQ:modules/project/ groupTesting.ts. - For M3-16b: word
needs_confirmation(itsGroupConsequences) in the set's page andaccessToRevokeon the project page's row (also true while a confirmed departure waits for the job: its access is not revoked yet);GroupSetUse.followsis the project's stop only — a group of a following copy may have stopped on its repository's deadline.
M3-15b-2b — Resync with the set and the drift¶
- Depends on: M3-15b-2a.
- Goal: the drift of a stopped copy (the project page), Resync with
the set through the same confirmation (PO1: allowed after the deadline,
naming every frozen repository it touches;
409 releasedonce the project is released), as a durable intent the job applies. - Tests: a resync after the deadline, refused after the release, its confirmation naming the frozen repositories.
- As delivered (branch
merge/M3-15b2b-group-resync): the product owner's R1–R3 are ADR-070's fourth amendment of 2026-10-05 and F-PROJ-13/14's wording. Migration0074_group_resync(projects.group_resyncjsonb of consequence keys,group_resync_at,group_resync_by).@quiz/domain:groupSyncPlan'skeepRepoOrphans(R1: a group with a repository whose set group is gone kept, its name and slug reserved, its members following the set),resyncPlan(the plan with every stop lifted, split as a set's write; R3's arrivals into a group without a repository once Accept is closed arejoinconsequences flaggedacceptClosedbysplitPlanitself, keyed by the set group's id when the resync creates the group),syncWork(what the job owes: the following copy's moves and the stored keys the set still asks for, each flaggedresync; an arrival whose departure no one confirmed dropped),deadlinePassed(projectAccept.ts: Accept'sdeadline_passedand R3's closed Accept);consequenceKey's format is persisted.Q:modules/project/groupResync.ts:resyncGroups(the classroom FOR SHARE, thenlockSync: the set FOR SHARE, the project FOR UPDATE;no_group_set,project_archived,released,classroom_archived; a draft, or nothing to resync — the lifted plan applies nothing and adds no consequence beyond the job's work —, is a 204 with no audit; the consequences ADDED to the job's work confirmed by digest throughdescribeConsequences, now withfrozenandacceptClosed— an R3 arrival never names a repository, even a failed provisioning's row name; the part without a repository applied in the request; every consequence left stored, the departures marked, the project due; auditedproject.group_resync:digest,consequences,frozenRepos,applied,deferred) andgroupsDrifted(ProjectDetail. groupsDrifted, published group projects, released or archived ones included: the fact, not the offer).groupCopy.ts:frameOf,lockSync(the job's and the resync's lock sequence, the row and its frame),copyWork(one read of the copy's work for the job, the request and the page),owesResyncDeparture(one predicate over the work read under the job's locks, forbeginDeparture— a departure out of a stopped group only while the work owes it — andcompleteDeparture— a resync's departure moves into its target, stopped or not, when the arrival the plan names, if any, is owed too, elseleft);completeArrivalwrites an R3 arrival too (no invitation);syncSteps/settleSyncdrop the keys no longer owed;RESYNC_DEPARTURE, the SQL twin ofconsequenceKey, keeps a stored departure's mark through a stop (stopGroups) and a set's write (markDepartures). The job audits a resync's revocation and invitationvia: "group.resync". Release:409 group_sync_pendingwhilegroup_resyncis not empty (R2). RoutePOST /app/api/projects/:id/ groups/resync(ProjectGroupResync,staffAccess), 204; contracts:GroupConsequence.frozen,.acceptClosed;released,classroom_archived,needs_confirmation,group_sync_pendinginPROJECT_REFUSALS. Web:group_sync_pendingworded at the release (en, fr);groupsDriftedfalse in the mock. Conservative choices (noted for review): an archived project is refused with M3-07'sproject_archived(worded for both); the resync stores the following copy's pending moves too (they then count for R2 and are auditedgroup.resync); a project archived with a resync owed keeps being revoked by the job (its invitations skipped: not live); a deadline passing between the confirmation and the job turns an unconfirmed arrival into a group without a repository intoleft. - Tests:
groupResync.db.test.ts(12): nothing to resync (204, before and after the deadline, a draft), every frozen repository named, a wrong and a stale digest; a frozen A→B move ends moved, auditedvia: "group.resync", the copy still stopped; a set's write after the confirmation never applied and a drift again; a set's write putting the student back between the job's read and its departure (held, nothing revoked); R1 (the group and its repository kept, no member); R2 (released only once applied); R3 (into a stopped group without repository, into one the resync creates, one moved at once); a following copy's group stopped on its repository's deadline (renamed at once, joined by the job); a repository's deadline applied mid-resync keeping its mark;no_group_set,classroom_archived, a released and an archived project refused; a student, another teacher, an impersonation and a token answered as nobody; a roster removal during the resync's refused revocation (502). Domain:resyncPlan, R1's kept orphan, R3,syncWork. Web: the release refusal's words. - For M3-16b: the drift banner and Resync on the project page
(
groupsDrifted, the action hidden oncereleasedAtorarchivedAt), the confirmation dialog listing the distinct frozen repositories (frozen) and the R3 arrivals (acceptClosed), the 204, and the refusalsreleased,project_archived,classroom_archived,no_group_set.
M3-16 — Groups (web)¶
Split in two PRs (orchestrator, 2026-10-05): what the API of M3-15a serves now (16a), and what waits for the group repositories (16b).
M3-16a — The Groups tab, a set's page, the project's set¶
- Depends on: M3-15a, M3-12.
- Goal: the classroom's Groups tab (fr Groupes) listing its sets;
a set's page: the students in no group and the groups, drag and drop
with a keyboard equivalent, Undo for a move (none reaches GitHub
before M3-15b), the random formation (size, smaller / larger); the
project form's group work with the classroom's sets
and Create new groups; a draft's set chosen on its page, the set a
group project follows shown there. Read
apps/web/DESIGN.mdand thequiz-uiskill first. - From M3-15a: the form and the project page word the refusals
no_group_set(publish) andunknown_group_set(create, PATCH) throught(), inen.tsandfr.ts, like the others ofPROJECT_REFUSALS; the hint kindgroupsnames its query roots inrealtime/hints.ts. - Decided for it (orchestrator, 2026-10-05): (1) the Groups tab and
the set's page ship to production; the form's group mode was to wait
for M3-15b-1 (Accept answered
409 no_group), which merged first: it is offered in every build, a membership change reaching a group repository answering409 has_repountil M3-15b-2 (orchestrator, 2026-10-05); (2) the routes are/classrooms/:id/groups(a route tab) and/classrooms/:id/groups/:setId, staff only, and/projects/:id/groupsgoes; (3) one primary on the set's page, Form at random, none once everyone is placed; (4)@dnd-kitafter the categorize board's pattern, copied, never imported; a "Move to…" menu per student; (5) Undo for moves only, the latest only, through the toast; (6) one write at a time per set, the cache from the latest answer. - As delivered (branch
merge/M3-16a-groups-web). What M3-16b and M3-17 inherit: - Routes (
router.ts):classroomGroupsandgroupSet(itsfromProject, in the query only;groupSetPageOfinproject/projectPage.tsbuilds it), neitherpreviewnorstudentSafe;projectGroupsandsoon.projectGroupsare gone.ClassroomView'sROUTE_TABShasgroups(after the Roster), whose header primary is New group set (straight to the new set's page). apps/web/src/group/:api.ts(useClassroomGroupSets,useGroupSet,useCreateGroupSet,setWrite— each write's body built by its route's schema —,useGroupSetWrites— the queue: one request at a time per set, an optimistic step drawn at once, the cache set from the latest answer once the queue is empty, rolled back to the last answer and read again when it ends on an error);groupRules.ts(pure:placeOf,withMove,sizesSummaryongroupSizes, the refusals' words — a 404 "no longer in the set", never the server's English —,projectsRefusal:set_in_useandhas_repowith their projects (GroupRefusalProjects), said above the board as links,stepZone);GroupSetList,GroupSetPage(name renamed in place, New group, menu: maximum size, duplicate, delete),GroupBoard(pointer and keyboard drag, the keyboard walking the zones in reading order; click then click with "Move here" buttons; a "Move to…" menu; a group renamed in place,duplicate_nameunder its field),RandomFormDialog,GroupSetPicker,parts.tsx;AppLink.tsx(a route as a real link) beside the router. An archived classroom's set is drawn read-only.- Primitives: the toast takes one action and a
key(Undo, the latest move only);Menu's panel scrolls inside the viewport;EditableTitle's button no longer wraps its pencil in a box sized to its content. - Keys and hints:
groupSetsKey(groupSetListsKey,classroomGroupSetsKey,groupSetKey), named by the hintsgroups,roster,projectsandclassrooms(an archive turns an open set read-only). - Projects: the form's group mode in every build (no gate left); the
draft's
groupSetId, sent in group mode only;unknown_group_setunder the picker, the choice cleared.ProjectGroupSeton the page: a draft's picker (PATCH groupSetId, its refusal under it), the set's link with?fromProject=<id>, follows / stopped following once published;no_group_setan alert pointing at the picker;unassigned_studentslinks to the set. - Mock (
mock/groups.ts,?groups=1): three sets on PRG1-2026 (pairs, named by a draft group project; "Projet final" with a group above its maximum and eight students in no group; an empty one), one on the archived PRG1-2024; the draft project in group mode without a set. Checked bycontract.test.ts; scenesclassroom-groups*,group-set*,project-new-groups,project-group*.
M3-16b — Rows per group, drift, Resync, confirmations¶
- Depends on: M3-15b-2, M3-16a.
- Goal: the project page's one row per group (its repository, its
members); the drift of a stopped copy and Resync with the set; the
409 needs_confirmationdialog of a membership write that reaches GitHub (ADR-070 §6), naming the consequences and sending the digest back, through the set's write queue; the access to revoke flag on a repository's row;409 has_repo(M3-16a's alert) giving way to the confirmation. - Decided for it (orchestrator, 2026-10-05, B1–B11): the rows stay
per student on the wire, each with its copy group, and the client groups
them; Release stays the server's primary, disabled while a confirmed
resync is owed;
has_repostays for deleting a group (no bulk "empty"); access to revoke amber, never red, not an alert; no Undo on a confirmed move, an Undo meeting the 409 confirmed the same; members by name and login, current members only; Resync never the primary, hidden once released or archived; the dialog a modal, never a second banner over the board. - As delivered (branch
merge/M3-16b-groups-web). No migration. - API (
Q:modules/project/detail.ts):ProjectDetailRow.group(ProjectDetailGroup: the copy group's id, name,stopped) — the groupseatRepos(…).seat()reads the seat's repository through, null for a student in no group of the copy, or reading their own individual repository (heig-classroom's lot 1); a repository no roster student reads carries its group (R1's group kept with no member);ProjectDetail.groupSyncPending(groupSyncOwed,groupResync.ts, the release's own R2 predicate) andcounts.groups(the copy's groups, 0 for an individual project). Tests:detail.groups.db.test.ts(a student in no group, R1's orphan row after a confirmed resync,groupSyncPendingbefore and after the job),detail.db.test.ts(none on an individual project). - The set's page:
group/ConsequencesDialog.tsx(a modal that scrolls: by project, then repository — "being created" whenrepois null —, then who loses and gets access; "changed meanwhile" on a stale digest; Confirm its one primary). The write queue (useGroupSetWrites, now{ write, confirm, cancel }) is HELD by409 needs_confirmation: the optimistic move stays drawn, every queued or new write is rejected unsent (WriteHeld, wordedgroups.confirm.held),confirm(request)resends the move withsetWrite.place(…, digest),cancel()puts the last answer back; Escape does not cancel while the confirmation is in flight. A confirmed move's toast has no Undo; an Undo meeting the 409 opens the same dialog for the reverse move.has_repois said for a group's deletion only. Pure rules (groupRules.ts):needsConfirmation(strictGroupConsequences),consequencesByProject,resyncSections. - The project page: one row per group (
repoEntriesinprojectPage.ts: by name, numeric, then the students in no group — "no group" —; the column "Group", sorted by its name; the repository and its scores once; members by name and GitHub login, "No member" for R1's orphan);RepoSheetby repository id, titled by the group, its members and whether it follows the set (oneMemberList,parts.tsx, for the row and the sheet); access to revoke an amber flag (repoFlags, outsidecounts.alerts); "N groups" in the counts.ProjectGroupSet: the drift (offersResync: drifted, neither released nor archived) as a warning alert with Resync with the set a secondary;POST …/groups/resyncasked bare, then confirmed with the digest (the dialog'skind="resync": distinct frozen repositories, "Will have no repository — Accept is closed", the rest by repository); a 204, applied at once or confirmed, refetches and says "Groups resynced with the set";released,project_archived,classroom_archived,no_group_set,needs_confirmationworded, and a 404 (not_found, once inapi.ts'sWORDED); "A resync is being applied on GitHub" whilegroupSyncPending, and Release disabled with its line under the header.GroupSetUse.followsworded "follow the set until the deadline". - Mock (
?groups=1&projects=1): "Mini-projet — en binômes" (published, following the pairs, a repository per group but every third, one with an access to revoke, one invitation pending) and "Labo 0 — en binômes" (locked, not released, a stopped copy the set drifted from: its resync names three frozen repositories and an arrival without one; confirmed, the copy takes the set, owed two reads, the release refusedgroup_sync_pendingmeanwhile; a resync with nothing reaching GitHub applies at once, 204); a move of the pairs reaching the following project's repositories answers409 needs_confirmation(mockDigest). Checked bycontract.test.ts. Scenesgroup-set-confirm,project-group-rows,project-group-drift,project-group-resync-confirm. - Tests (web):
groupRules.test.ts,api.test.tsx(the hold: a 409 then the digest, queued writes unsent, a stale digest held again, Cancel),GroupSetPage.test.tsx(menu, click then click and Undo each meeting the 409, confirmed, cancelled, stale;has_repoon a group's deletion),projectPage.test.ts,ProjectPage.test.tsx(group rows with no-group and orphan rows, the badge, the sheet, the drift hidden once released or archived, the 204, the dialog and its digest, each refusal, Release held). - Conservative choices (noted for review): the board is not redrawn
read-only under the dialog — the modal takes the input and the queue
refuses writes —; the component test drives the move by the menu and by
click then click, not by a pointer drag (the drag reports through the
same
onMove); a group's stop on its repository's own deadline is said in its sheet, not as a badge on the row; the drift alert is hidden, not only its button, once released or archived; an archived CLASSROOM is not onProjectDetail, so the drift still offers Resync there and the refusalclassroom_archivedis worded (left for review).
M3-17 — Groups formed by the students (ADR-070 lot 2)¶
- Depends on: M3-15a, M3-16, M3-09.
- Goal: F-PROJ-22: a set opened to the students until a date, with a
binding maximum size; the student routes (create and name, join, leave,
rename) on
readableClassroom's student branch, refused in impersonation,sebandkiosksessions; the module's student view (N-SEC-20) and its tests; the student's screen. - Decided for it (product owner, 2026-10-05, ADR-070's third
amendment): S1 a student Groups tab at
/classrooms/:id/groups, drawn whilehasGroups; S2 the sets a student sees: open, or named by a published project that is not archived; S3 a row "Form your group until …" in Open now (classroom page and home), never the accent, no notification; S4 the students in no group, by name, while open. - As delivered (branch
merge/M3-17-student-groups). Contracts (group.ts):GroupSetPatch.openUntil;openUntilandopenonGroupSetSummaryandGroupSetDetail.set;GroupMemberName,StudentGroupSet(s),StudentGroupCreate,StudentGroupJoin,StudentGroupSetCard; refusalsmax_size_required(422),set_closed,set_frozen,group_full(409);student.ts:StudentActivities.groupSetsandStudentClassroomPage.hasGroups. No migration (open_untilsince 0064).guards.ts:openSet,studentVisibleSet(S2, one SQL rule),publishedProject(also read byfindStudentProjectandfindStudentProjectView; the other sites keep their own conditions),selfFormingSeat(the one rule of who forms groups: own portal session, claimed STUDENT seat),findStudentGroupSet/studentGroupSet(the writes' loader; anyone else the 404).project/groupCopy.ts:setFrozen(anEXISTSof the copy'sHAS_REPOover every project naming the set, stopped and archived ones included).modules/group/service.ts:patchGroupSetopens/closes (max_size_requiredwhen open after the write), a duplicate born closed;writeSethintsgroupsto the claimed students'user:topics when the set reaches them before or after the write (neverclassroom:); one read,setRows(readOnly,open=openSet,frozen=setFrozen, as columns), behind the staff's list and detail, the student view andanyVisibleSet; the staff's and the students' writes shareinsertGroup,renameIn,placeIn(itscapacityfor the students); the students' writes throughstudentWrite— open (no grace) and not frozen, re-read throughsetRowsunder the set's lock before the write and any step,group_fullat the maximum or above, one audit per write (group.create|rename|member_move,self: true, the line) — and the student viewstudentGroupSets({ serverNow, sets }, fields picked by hand, three reads whatever the number of sets; closed: own group only, nounplaced),studentGroupSetCards(open and not frozen, a claimed STUDENT seat's only: a teacher in the student view gets no row),hasStudentGroupSets(read byactivity/service.ts). Routes:GET /app/api/classrooms/:id/group-sets/student(readableClassroom, the student payload forced;writableonly for the caller's own portal session on a student seat),POST /app/api/group-sets/:id/student/groups,PUT|DELETE …/student/membership,PATCH …/student/groups/:gid, each answering the classroom's list as the writer reads it. Web:group/StudentGroups.tsx(the tab: one primary "Create a group" for the first set the reader may write while in no group of it, Join on a group not full, Rename and Leave on their own, drawn first; closed: own group or "your teachers will place you"; re-read whenopenUntilpasses, byserverNow), its hooks ingroup/api.ts(studentGroupSetsKeyunder the student's classroom),GroupSetRowinstudent/cards.tsxon the home and the classroom page, the tab inStudentClassroom(classroomGroupsnowstudentSafe, slot Courses, role-dispatched inApp.tsx); the staff's set page opens the set from its menu (OpenDialog: date, maximum, the risk said) and says until when with Close to students; the list badges an open set; hintgroupsalso names thestudentroot. Mock: "Projet final" open three days, the student persona in no group of it, the four writes, the row and the tab (?groups=1); scenesstudent-groups*,student-home-groups,student-classroom-groups-row,student-view-groups,group-set-open-dialog. - Tests:
group/studentGroups.db.test.ts(12, on a server with the development login, and one without for an impersonation's403 impersonation_read_only): opening and closing (max_size_required, a past date, a duplicate closed,classroom_archived, audited); create (default name in the student's language), join,group_full, leave, rename (own only,duplicate_name), auditedself; two joins racing for the last seat; a draft's copy stepped by a student's create;set_closed(no grace) and the 404 of an invisible closed set;set_frozenwith a repository in a following and in a stopped, archived copy (and no Activities row then); an archived classroom; an impersonation (development), a teacher in the student view, a staff seat, a stranger, a token (404),sebandkiosk(401); the leak search over every student-side body (a second classroom's set, a closed unused set, another group's members after closing, e-mails, a GitHub login, roster line ids,claimed,usedBy) for the student, an impersonation and a teacher in the student view; hints touser:neverclassroom:; the Activities row andhasGroups, none to a confined home.accessSweeplists the student read. Web:StudentGroups.test.tsx(13),StudentClassroom.test.tsx(the tab and the row, never the accent),GroupSetPage.test.tsx(the opening dialog, the banner's close), the router and bottom-bar tests, the mock contract check. - Known gaps / for later: the race for a group's last seat is tested on
PGlite, a single connection that serialises transactions anyway; the
set's FOR UPDATE is what holds it on PostgreSQL (review, 2026-10-05); a closed set used by a published project has
no mock scene (the component test draws it); archiving a classroom does
not clear
open_until— an archived classroom's set is never open by rule (openSet), and stays closed if it is unarchived after its date.
M4 — Journal¶
M4-01 — Renderer package, journal schema and contracts¶
- Depends on: M1-01, D03, D14, D15. ‖ M2.
- Create:
packages/docrender(renderPage,journalTree,journalRepoNameon@quiz/domain'srepoName(I17), the code tokenizer — Quiz'sapps/web/src/markdown/highlight.tsthen imports it);Q:db/journal.tsin the shape of04-journal.md§4.2 (one row per classroom, D03);packages/contracts/src/journal.ts(warnings as codes). No import step (written by M8-01,01-strategy.mdprinciple 3). - Tests: port
render.test; journal tree tests.
M4-02 — Journal read side and ingestion¶
- Depends on: M4-01, M2-02, M2-04.
- Port from:
C:journal/ingest.ts,C:journal/repo.ts, the read routes and asset route ofC:modules/journal.ts. - Create: the journal's routes on
readableClassroom(created by M5-01: student payload for impersonation whatever it asks, and for the staff only on an explicit request that narrows, 05 §5.7), passingstudentViewfrom a?view=studentquery schema of the contracts; fixes J1–J4 of §4.3; webhook registration; queuejournal.ingest. - Move: the
hasJournalread of the student's classroom page (activity/service.ts, M5-01) intomodules/journal/service.ts. - From M4-01: ingestion stores
cleanSource(md)(no NUL) asmarkdown, and renders each page twice (html_staffwith every page inctx.pages,html_studentwith the student-visible ones only,04-journal.md§4.2); the asset route is registered onJOURNAL_ASSETS_PATH(":id")of the contracts. On top of N-SEC-13's CSP andnosniff, the asset route serves an SVG withContent-Security-Policy: sandbox(orContent-Disposition: attachment), so a committed SVG opened directly runs nothing. - From M2-04: the journal's plugin registers
onEvent("push", handler)(andonEvent("repository", …)for a rename or a deletion) frommodules/github/service.ts;githubnever importsjournal. The handler receives(app, config, delivery)with the stored payload, finds the journals ofrepository.idwhoseafteris notlast_commit_sha, and sendsjournal.ingest(the queue whose policy J2 settles); it must be idempotent, since a delivery is retried and replayed. It registers noonReceipt: a journal needs no push receipt. The J4visible_fromsweep is not a scheduled task: spec 05 §5.11 and D10 make it a clock-boundTickTaskof the ticker (everyMs60 s), since a page's visibility is a date and must not depend on an admin setting. - Tests: port
ingest.db.test,journal.db.test; a student cannot fetch an asset referenced only by a draft page; two concurrent ingests converge; an SVG asset carries the sandbox (or attachment) header. - As delivered (#414):
Q:modules/journal/{routes,service,studentView,ingest,repo,events,jobs}.ts(+testing.ts, the fake repository), registered inapp.tsonly whengithubApp(config)is non-null. What M4-03, M4-05 and M8-01 inherit: - Routes (
studentRoute,IdParam/JournalPageParams/JournalAssetParams,JournalViewQueryvalidated first:?view=staffis a 400):GET /app/api/classrooms/:id/journal(Journal),GET …/pages/*(JournalPage),GETonJOURNAL_ASSETS_PATH(":id") + "/*"; all throughreadableClassroomwithstudentViewfrom the query; portal sessions only (nosessionsdeclared). The student payloads come fromstudentView.tsalone (visibleToStudents(), the one predicate, DBnow(); each payload parsed by the strict contract schema); a classroom without a journal is a 404 for the student payload and a staff payload withrepository: nullandproposedName(journalRepoName(room.name)) for the staff. - Assets: ETag =
"<blob sha>"(304 onIf-None-Match), N-SEC-13's headers, an SVG's CSP gets; sandbox. J1: a student's asset needs a pagevisibleToStudents()whoseasset_pathsholds it. - Ingestion (
ingestJournal(app, config, classroomId)→{status: "ok", commitSha, pages, assets} | {status: "error", code} | {status: "superseded"} | null), J2 without a lock during GitHub reads (04 §4.3, "As ported"): snapshot ofclassroom_journals.version, GitHub read and pages rendered outside any transaction (every call bounded: no retry,noRateLimitWait, 30 s, the token fetch raced against the same deadline), then one short transaction,SELECT … FOR UPDATEand a write only ifversionis unchanged (+ 1); a failure is its own compare-and-set, the pages kept. Lost twice: the job is re-sent (superseded). Every writer of the row bumpsversion. A page or an asset over 5 MB is not copied. The repository is resolved by id (GET /repositories/{id}, a missed rename is followed); 409 = empty repository (copy emptied, ok); 404/422 on the branch =ref_not_found; no file underroot_path=root_not_found; truncated tree =too_large; 403/401 or no active installation =forbidden; 5xx, network, rate limit =github_unavailable(the only code the worker retries). Pages are rendered fromcleanSource(md),html_stafffirst, thenhtml_studentof every page byrenderStudentPages(the visible set read byvisibleToStudents()), which stamps the new columnclassroom_journals.student_rendered_at(migration0049, which also addsversion). requestIngest(app, config, classroomId)(service) sendsjournal.ingest(standard, no dedupe, 3 retries), or ingests inline without a queue: M4-03's Refresh and its re-ingestion after a save call it (the Refresh route is M4-03's, per its card).- Webhooks (
jobs.ts,onEvent):pushonrefs/heads/<ref>→requestIngestfor every row ofgithub_repo_idon that branch whoselast_commit_shadiffers fromafter(a deleted branch is ingested and endsref_not_found);repositoryrenamed→full_name,deleted→error/repo_not_found, one statement bumpingversion. - J4:
TickTaskjournal.visible_from(60 s) →sweepVisibleFrom: the classrooms with a non-draft page whosevisible_fromis past and later thanstudent_rendered_atgethtml_studentre-rendered (no GitHub call) and the hint, under the row'sFOR UPDATE SKIP LOCKED(a journal being written is skipped). - Assets:
INERT_IMAGE_HEADERSof the pool plus the cache line, with N-SEC-13's CSP (notsandboxfor every asset: a PDF viewer does not run in a sandboxed document); an SVG adds; sandbox. githubStatus(err)(github/app.ts) is the one reader of a GitHub error's status.- SSE: hint kind
journal(contractsHintEvent,events.ts, the web'sHINT_ROOTS→["journal"]) onclassroom:<id>. hasJournal(db, classroomId)moved tomodules/journal/service.ts;activitycalls it.github's D28 check still readsclassroom_journalsby join (githubnever importsjournal).- Audit: none. The nine
journal.*actions of §4.1 are all staff writes (M4-03); a synchronisation's outcome is the row's sync state.
M4-03 — Journal writes¶
- Depends on: M4-02, M2-03.
- Goal: create (no adoption, suggested name), use an existing
repository of the classroom's organization (root path honoured), remove
(the repository is kept), refresh, preview, save with
baseSha, add, delete, upload (committed into the repository); invitations to course staff with a linked login. - Contracts: M4-01 shipped only the read half of
packages/contracts/src/journal.ts; the write bodies move here, to be shaped by their handlers (lean review of #371): the repository name, the branch, the root folder,JournalCreate,JournalUse, the name-taken 409 with its suggestion, the error codes, the refresh result, the preview body and payload,baseSha(a blob sha), the save body and result, the add-a-page body, the created-file payload, the markdown size cap (500 000). Hardening already designed in #371, to write again: a branch refuses a leading-,..anywhere, an empty segment (//, a leading or trailing/), a segment starting with.(so.and/./) and a.lockending; the root folder is trimmed of surrounding slashes, capped atJOURNAL_PATH_MAXand must passsafeJournalPath. - From M4-02 (J2): every staff write — save, add, delete, upload,
choose or remove a repository — bumps
classroom_journals.version, as the ingestion does, so an ingestion that read GitHub before it never commits over it (modules/journal/ingest.ts). - As delivered (#415):
Q:modules/journal/writes.ts(the service), the routes inroutes.ts(registerWrites), the GitHub writes inrepo.ts, the fake's write routes intesting.ts(writeRoute,fakeWorld). What M4-05, M4-06 and M8-01 inherit: - Routes, base
/app/api/classrooms/:id/journal, staff only throughaccessibleClassroom(404 otherwise; impersonation 403,seb/kiosknobody):POST base(JournalCreate, 201 staffJournal),POST base/use(JournalUse, 201),DELETE base(204, idempotent),POST base/refresh(202, no body: the outcome is the row'ssyncStatusafter thejournalhint),POST base/preview(JournalPreview→JournalPreviewResult,readOnly),PUT base/pages/*(JournalPageSave→JournalFileWritten),POST base/pages(JournalPageAdd, 201JournalFileWritten),DELETE base/pages/*(204),POST base/assets/*(raw body ≤ 5 MB in a child context of its own, Fastify's 413 over it;JournalUploadHeadersandassetContentType, now in@quiz/contracts; 201JournalFileWrittenwithpage: null). - Paths:
safeJournalPathrefuses every segment starting with.(so.github/workflows,.gitignore): no write touches repository furniture, and the ingestion copies none of it (pages included). - Refusals:
JournalErrorCode(the sync codes plusnot_connected,journal_exists,no_journal,name_taken,conflict,page_exists,type_mismatch,empty_upload), body{error, message}withmessagethe code; 409 for the state of things, 415type_mismatch, 400empty_upload, 503github_unavailable.name_takencarriessuggestion(JournalNameTaken): the name with the classroom id's first 8 hex characters, all 32 if that is taken too (checked once). - Which route needs what: create and use an organization with Quiz's App
active (
not_connected) and no journal; save, add, delete, upload the journal and the organization; refresh and preview the journal only; remove nothing, so a journal whose App was uninstalled can still be removed and the classroom then disconnected (D28, tested). - Saves: GitHub first (Contents API,
baseShathe lock, 409/422 ⇒conflict, nothing merged), thenversion + 1, theningestJournalawaited (not the queue: the response carries the page);pageis null when the copy did not reach the committed blob. Create and use go throughrequestIngest(queued in production); a new row starts at a randomversion, so an ingestion that snapshotted a removed row never matches a fresh one. Every write callsbumpVersion(ingest.ts). - Author:
displayNameand a noreply address, never the user's email (N-DATA-02): linked ⇒<id>+<login>@users.noreply.github.com(attributed by the immutable id); not linked ⇒quiz-<user id>@users.noreply.<host of PUBLIC_URL>. The committer stays the App (GitHub signs the commit). Text written into GitHub (seed README, description, default commit messages) is English, D12's suggestion; D12 stays open. - Invitations (D27): every course staff member with a
github_accountsrow, current login throughlinkedLogin,permission: "push"; one already at push or above (GET …/collaborators/{u}/permission) is left as is,accepted(an invitation would lower an admin); onejournal.inviteeach (outcomepending,accepted,stale,failed); a failure is logged and never fails the step. - Every journal GitHub call, the ingestion's and the writes', goes
through
boundedClient(a request hook: no retry,noRateLimitWait, 30 s), the shared adapters included;once()is gone. - Audit:
journal.create|use|remove|refresh|save|add|delete|upload|invite, subject the classroom.
M4-04 — Web: journal reader¶
- Depends on: M4-02 contracts, M1-04, M1-05. ‖ M4-05, M5-02.
- Note: students reach the reader only through the student classroom page (M5-02); a journal is live for students once both are merged.
- Scenes:
journal-student,-phone,-teacher-hidden,-empty,-not-found.
M4-05 — Web: the Journal section of Settings, the teacher journal tab¶
- Depends on: M4-03 contracts, M2-07, D24.
- Goal: the Journal section of the classroom's Settings (create, use a repository, sync state, Refresh, remove); the Journal tab, present only when the classroom has a journal.
- Reader (from M4-04): the reader ships with no staff action. This task
adds Refresh (secondary) to the reader's header, in
ReaderHeader'sasidebeside the sync state, and invalidatesjournalKey(id, view)on success; it words the "no page yet" hint for the staff again once Refresh exists. - Scenes:
classroom-settings-journal-none|create|use|set,journal-tab-pages,-sync-error. - As delivered (#416): what M4-06 and the later tasks inherit:
Q:apps/web/src/journal/JournalSettings.tsx, inClassroomSettingsbetween GitHub and "Archive and delete": one line ("connect first") until the classroom is connected; then "Create a journal" (FormDialog, the name prefilled withproposedName; a 409name_takenoffers "Create" in one click) and "Use a repository" ( FormDialog; branch and folder behind a "Branch and folder" disclosure that opens by itself onref_not_found/root_not_found); once set, the repository (a link to GitHub), branch and folder, the sync state with its check level (LevelIconofui: ok, unknown while pending or refreshing, blocker on error, with its reason), Refresh, "Remove the journal" (confirmeddanger, says the repository is kept). Nothing accented. A 404 on the GitHub or the journal route draws nothing. A journal is still shown (and removable) on a classroom that lost its link.Q:apps/web/src/journal/api.ts:useStaffJournal(keyjournalKey(id, "staff"), shared by the tab, the Settings and the reader),journalRefusal,journalErrorText,nameTakenSuggestion,useJournalRefresh— "Refreshing…" from the click until the row'ssyncStatusorlastSyncedAtmoves (the write's ownmutationhint refetches an unchanged row first, so "until the next fetch" would stop too early; thejournalhint follows every ingestion), giving up after 60 s; no polling.JOURNAL_ERRORS(words.ts) words everyJournalErrorCode; anything else iserror.server, never the server's text (FormError describe).- The teacher's Journal tab is
ClassroomView routeTab="journal"(ROUTE_TABS.journal), shown only while the staff payload has a repository; its address without a journal (or without an App) replaces itself with the classroom's, as the student page's does whenhasJournalis false. The reader lost its breadcrumb (it is always a tab now); its staff bar (StaffBar) holds the sync state and Refresh (secondary,sm). Edit (M4-06) goes in that bar. classroomJournalis no longerpreview; the student page's Journal tab shows onhasJournalalone.CLASSROOM_PAGESnow gates only the Grades tab and the project pages.api()reads no body from a 202 (Refresh), as from a 204; every other success is parsed (M4-06 dropped the "declared empty" case).- Mock: create, use, remove and refresh change the journals for the
page's life (
ORG_REPOSare the taken names);?journalerror=1; the GitHub disconnect and the student page readhasMockJournal. - Scenes:
classroom-settings-journal-none|create|name-taken|use|set|sync-error|remove,classroom-journal-teacher,-refreshing;journal-emptyis gone (a teacher without a journal is sent to the classroom).
M4-06 — Web: the journal's WYSIWYG editor¶
- Depends on: M4-05, D25.
- Goal: Quiz's markdown editor (
apps/web/src/markdown/) on a journal page, source mode beside it; front matter as fields; image upload committed into the repository with a relative path; save withbaseSha, conflict kept as a draft. - Reader (from M4-04): this task adds Edit (the primary action,
inside a page) to the reader's header, in
ReaderHeader'saside, and swaps the article for the editor. - Tests: the five conditions of D25 — the round trip over the journals of classroom's production (fetched read-only with classroom's App, into a fixture kept outside this public repository, as the exam corpus is; the committed test runs on a synthetic journal and skips the real one when absent), front matter, images, relative links and KaTeX, no write of an unedited page.
- Scenes:
journal-edit,-source,-conflict. - As delivered (#417): D25 holds in full, WYSIWYG from the start (no fallback to the source editor). What later tasks inherit:
- Edit is the
primaryof the reader'sStaffBar, beside Refresh and two icon buttons (add, delete a page,Actions); all three writes are off whilerepository.editableis false. The editor replaces the page on the same route by local state (editinginJournalReader), is left when another page is on view, and snapshots the page it opened (text andblobSha) for its life; the conflict's reload remounts it. src/journal/editor/:JournalEditor(bar + body, laid out by the reader through a render prop),PageFieldsForm,AddPageDialog;frontMatter.tssplits the---block off before the editor sees the body and writes the four fields (title,date,draft,visible_from) LINE BY LINE: an unchanged field keeps its line, a changed one rewrites its own, emptied ⇒ removed, new ⇒ appended, every other line verbatim, an empty block drops its fences.- The journal's schema:
richTextExtensions({ journal: true, imageUrl })(markdown/journalSchema.ts):_/*and__/**kept, bullet markers (-,*,+), thematic breaks, backslash hard breaks and<autolinks>kept, raw HTML (block and inline) as literal atoms shown as text and written back verbatim; no cloze.RichTexttakes the web-onlyRichTextHostProps(journal,imageUrl,reconcile,onSourceChange); the source pane's text is emitted as typed. reconcile.ts: the editor's output is cut into blocks (marked's lexer) and matched (LCS) against each source block as the editor would spell it unedited (spellWith, with the page's link definitions): matched ⇒ written as READ, blank lines between former neighbours kept, lead and trail kept; unmatched ⇒ the editor's spelling; a link definition stays after its source predecessor's output position, even when that block was edited or deleted.- Accepted normalisations, in an EDITED block only (every other block is
byte for byte,
roundtrip.test.tsasserts the list): a table is re-padded to its columns (its alignment row too); setext ⇒ ATX heading;~~~and indented code ⇒ backtick fence;1)⇒1.; an ordered list is renumbered;$$x$$on one line ⇒ three lines; a bare URL ⇒[url](url); a literal*/_/`/[/]/~/\is escaped; a bare&or<in text ⇒ an entity; a reference link ⇒ inline (its definition stays). The file's final newline is kept. - Pictures (
images.ts): uploaded to<page folder>/images/<slug>-<6 hex>.<ext from the type>throughuploadJournalAsset(content type =assetContentType(path)), inserted asimages/<…>relative to the page, neverasset:; drawn from the browser's copy (object URL) until a saved page references it, any other relative image fromJOURNAL_ASSETS_PATH. Over 5 MB or an unserved type ⇒ a toast, nothing sent. The size and rotate tools of the image stayasset:-only. Accepted: a picture is committed on drop, before Save, so a Cancel leaves the file in the repository unreferenced (the guide says so,docs/guide/classrooms.md). The object URLs are revoked with the editor. - External images (F-JRN-09): the resolver answers
REFUSED(markdown/imageUrl.ts) for anything that is not a session copy or a relative path inside the journal (https:,//,asset:,/abs,../out of the tree):ImageViewshows the alt text in a dashed frame andrenderHTMLwrites nosrc(data-assetkeeps the reference); the markdown is untouched. - Front matter is read through
@quiz/docrender/frontMatter(its own module now:FRONT_MATTER, three groups,splitFrontMatter,asBoolean), so the fields show what the renderer does (draft: 1is a draft;...closes nothing). Asset URLs come from@quiz/docrender/assets. The web app never imports the package root. - Bundle:
JournalEditorislazyin the reader, a chunk of its own (Tiptap, marked, yaml); the reader's chunk, which students load, holds neither yaml nor marked (checked on a production build). - An autolink stays
<href>only while its text is its address (a plugin drops the flag once the text is edited). - Save sends
composePage(...)with the openedbaseShaand the optional description; Save is off while that markdown equals the page's byte for byte (D25 condition 5). 409conflict⇒ the red block above the sheet, draft kept, Save off, "Copy my text" (clipboard), "Reload the page…" (confirmed). Source view ⇒ "Preview" on demand throughPOST …/preview. - Leaving:
useLeaveGuard(dirty, ask)inrouter.tsholdsnavigate, undoes Back/Forward until the answer, and setsbeforeunload; Cancel asks the same question when dirty. - The D25 test:
journal/editor/roundtrip.test.tsoversynthetic-journal/(four pages, every construct the card names) and overJOURNAL_CORPUS_DIRwhen set (skipped with its reason in the title otherwise). On classroom's production journals (5 pages, fetched outside this repository): byte-identical, before and after the review round. - Mock: save, add, delete, upload and preview, rendered with
@quiz/docrender;?journalconflict=1makes every save a 409. - Scenes:
journal-edit,-source,-conflict,-frontmatter,-unsaved.
M4-07 — API: journal modes, schema, GitHub mode read-only¶
- Depends on: M4-03, D29 (ADR-057). First of the two-modes series
(
04-journal.md§4.5). - Goal:
classroom_journals.mode; the repository columns (github_repo_id,full_name,ref) nullable under a CHECK (set ingithub, null inquiz); existing rows migrate togithub;journal_pagesgains aversion(the Quiz-mode order and parent are M4-10's); thejournal_page_revisionstable. A GitHub-mode journal becomes read-only: M4-03's save, add, delete and upload refuse it (a coded refusal, not a 404). The staff payload carries the mode and, in GitHub mode, each page's Edit on GitHub URL (the file on the journal's branch, under its root folder). - Files:
Q:db/journal.ts, one migration,packages/contracts/src/journal.ts,Q:modules/journal/writes.ts,service.ts,studentView.ts(unchanged output: assert it). - Tests: the CHECK refuses a
quizrow with a repository and agithubrow without one; migrated rows aregithub; every M4-03 page and asset write on a GitHub-mode journal is refused and writes nothing; the Edit on GitHub URL (root folder, branch, a path with spaces); the student payload carries neither the mode's URL nor anything new.
M4-08 — API: the Quiz-mode backend¶
- Depends on: M4-07.
- Goal: create a Quiz-mode journal without GitHub (no App, no
connection; the module's Quiz-mode routes register when the
GITHUB_*variables are absent), with an empty home page; save (pageversionlock,409 conflict), add, delete, asset upload (relative path beside the page, sha256 asblob_sha), each in one transaction that re-renders, recomputesasset_pathsand records a revision on save; revisions list and restore; assets kept until the journal goes (no collection: a restored revision needs its images, D29); remove and classroom deletion delete the only copy (the API requires the typed classroom name when pages exist, F-JRN-04). Audit:journal.createwith the mode, the Quiz-mode page writes (the existingjournal.save|add|delete|upload, orpage_*names: decide and record it in the handoff),journal.restore. Writes frozen during a mode switch (a flag the switch tasks set). Settle, and record in D29, what deleting a page does to its revisions. - Files:
Q:modules/journal/(aquiz.tsbesidewrites.ts),packages/contracts/src/journal.ts,apps/api/src/audit.ts. - Tests:
*.db.test.ts— create with no App configured; a staleversionis a 409 and writes nothing; a revision per save, restore is a new save and audited; an asset referenced only by a draft is a 404 to a student (J1), served once the page is published; the student exit extended (05 §5.7): a former revision's content searched for in every student response, for the three student callers; impersonation andseb/kiosksessions never write.
M4-09 — Web: mode choice, standard editor, GitHub mode read-only¶
- Depends on: M4-08, M4-06.
- Goal: the mode as a segmented control in the Journal section of
Settings ("In Quiz" / "In a GitHub repository", fr "Dans Quiz" / "Dans
un dépôt GitHub", a one-line explanation each; the GitHub option needs
a connected classroom). In Quiz mode: Edit opens the platform's
standard
RichText(as question statements), the front matter as fields, relative-path image upload, conflict kept as a draft, revisions listed and restorable. In GitHub mode: Edit on GitHub is the primary action of theStaffBar, Refresh secondary, no add, delete nor upload. Remove: the confirmation of F-JRN-04 per mode. Deletejournal/editor/reconcile.ts,markdown/journalSchema.ts, thejournal: truemode ofrichTextExtensionsand their tests. Whether F-ORG-13's "Connect to GitHub" accent stays the Settings' one accent when a Quiz-mode journal exists is a question for the product owner, asked before the scenes are taken. - Files:
apps/web/src/journal/,apps/web/src/markdown/tiptap.ts, i18n en + fr, the mock. - Scenes:
journal-settings-mode,journal-edit(standard editor),journal-github-readonly,journal-revisions,journal-remove-quiz. - As delivered: the product owner kept "Connect to GitHub" as the
Settings' one accent, whatever the journal (projects need it). The mode
is a
Segmentedin a settings row, its description the chosen mode's line; on a platform without Quiz's App, In Quiz only (no control). The staff bar is drawn bymode, never by "has a repository": Quiz mode = the Pages menu (Actions menu: add, History, Deleted pages, delete) + Edit; GitHub mode = sync state, Refresh, Edit on GitHub (LinkButtonto the page'seditUrl, new tab). History and Deleted pages are sheets (journal/JournalHistory.tsx), a revision rendered throughPOST …/previewor shown as markdown. One typed-name confirmation:useConfirm({ typeToConfirm })(confirm.tsx), used by Remove the journal and by the classroom's deletion (?confirm=, both decided byremovalNeedsName). The editor is the standardRichTextwithlongForm(the.md-doctype only, no schema change);reconcile.ts,journalSchema.ts,spellWith, the round-trip test, the synthetic journal and.rt-raw-htmlare gone;JournalRepository.editabletoo (contracts and API). Mock:?journal=1is Quiz mode,?journalgithub=1GitHub mode, with revisions and a deleted page.
M4-10 — Rename, reorder, nest (Quiz mode)¶
- Depends on: M4-09.
- Goal: rename a page (its title), move it among its siblings and
under another parent; the path never changes (F-JRN-17). API and web.
Audit
journal.reorder. Adds the Quiz-mode explicit order and parent columns onjournal_pages(deferred from M4-07);parent_pathkeeps its meaning, the directory of the file. - Tests: links and the navigation after a move; a move to a descendant refused; the student navigation never names a hidden page.
M4-11 — Move to GitHub¶
- Depends on: M4-10, M2-03.
- Goal: a Quiz-mode journal moves into a NEW repository, or an EMPTY
one, of the classroom's organization (never one with content, ADR-049
point 6): one initial commit with every page and asset, numeric
prefixes from the order (steps of ten), relative links rewritten; the
row becomes
github, read-only; the staff invited as for a creation. Writes frozen while it runs. Auditjournal.export. - Tests: a non-empty repository refused; the committed tree renders, through ingestion, to the same student HTML as before the move; a save during the move refused.
M4-12 — Bring back into Quiz¶
- Depends on: M4-08.
- Goal: a GitHub-mode journal becomes a Quiz-mode one from its copy;
the repository is detached, never deleted nor changed. The confirmation
lists what is left behind (files of the repository the copy does not
hold) and says that the first save normalises the markdown. The order
and parents come from the file names. Writes frozen while it runs.
Audit
journal.import. - Tests: paths, order and assets preserved; no GitHub write; the
disconnect
409(F-GH-04) no longer applies afterwards.
M4-13 — Copy a journal from another classroom (later)¶
- Depends on: M4-08. Not in the first version (D29).
- Goal: create a Quiz-mode journal as a copy of another classroom's
journal of the same course (the next semester), both under
staffAccess.
M5 — Student classroom page and gradebook¶
M5-01 — API: the student's classroom¶
- Depends on: M1-03 (projects and journal plug in when they land).
- Goal:
GET /app/api/student/classrooms(the Courses list) andGET /app/api/student/classrooms/:id— header, activities by section throughActivityKind.studentCards,hasJournal,hasProjects; loaded by the student's own enrollment (404 otherwise). - Tests: leak test (no draft, no other student's data); 404 matrix.
- As delivered (#377): payloads in
contracts/src/student.ts;readableClassroom(app, req, reply, params, { studentView })inmodules/guards.ts, its rule the pureclassroomPayload.
M5-02 — Web: student classroom page¶
- Depends on: M5-01, D07.
- Goal: §5.2 — the Courses route (sidebar and bottom bar, D07), the
classroom page (Activities, Journal tab when present), pressable home
cards; Grades stayed an anchor of the home until the global
/gradespage (2026-10-01, below). - Scenes:
student-courses,student-classroom,-journal,-empty,-error, phone and desktop.
M5-03 — Gradebook module¶
- Depends on: M3-08a, M3-08b, D06.
- Create:
Q:modules/gradebook/owninggradebook_columns(nullableevaluation_id/project_id+ CHECK exactly one, weight, position);packages/domain/src/gradebook.ts(weighted mean); teacher Grades API, CSV/xlsx (staff rows dropped, ADR-018 §5). - Tests: equals per-evaluation results on the seeded world; only released grades; polls never.
- Note (2026-10-01): the student's global Grades page (
/grades,GET /app/api/student/results→StudentGrades) exists before this task; once D06 is settled the gradebook becomes its data source, under the same feedback-policy filter (F-RES-04). - Note (product owner, 2026-10-01, from M3-01): the M3 tasks give no
score to a student without a repository. The score a teacher sets for a
student who never accepted a project (F-GBOOK-01, 06 no. 48) and the
absence mark are this task's: where that score is stored is decided
here (M3-01's
project_reposhas no row to hold it). - From M3-08b (2026-10-04): a released project's grade is read, not
stored: the gradebook reads the LIVE final score (
repoScoresofmodules/project/detail.ts, orresolveFinalScorewithteacherMax) passed throughreleasableScore(project, repo, final)(detail.ts: a non-live repository's to-verify final is no score), graded byscoreGrade(points, max, projects.grading_scale), and marks the cell "changed after release" whenchangedAfterRelease(true, final, { released_points, released_max })says so — the same reading as the staff's page (decision 3). A repository with a null snapshot (released_points IS NULLafter the release) had no score: an empty cell. The source of a cell (I42) isfinal.source:teacher,revieworci. A teacher's score carries its own maximum (teacher_max): never rescale it to the CI's. - Split (2026-10-05): M5-03a is the domain, the tables and the API (below); M5-03b the CSV/xlsx export (F-GBOOK-04, staff rows dropped, ADR-018 §5, the format of F-RES-02, and how a mark travels in the file).
- As delivered (M5-03a), ADR-074,
migration
0075_gradebook: packages/domain/src/gradebook.ts:CellOutcome(grade, absent, empty),resolveCell(a mark wins),notTakenCell,gradebookMean(integer tenths, weights 0 to 10 at the tenth),countsByDefault; unit-tested at 100 %.- Tables
gradebook_columns(exactly one ofevaluation_id/project_id,weight,counts,position; made by the first write that names the activity, defaults otherwise),gradebook_marks(absent|score, staff comment, unique per column and student) andgradebook_settings(mean_published, one row per classroom, off without one). The exercise opt-in is the per-columncountsflag, not a classroom setting. ActivityKind.gradebookEntries/studentGradebookEntries(modules/activity/): the evaluation adapter readsresultsView(staff) andstudentGradesnarrowed to a classroom (student); the project adapter readsproject/gradebook.ts(projectLiveScoresthroughrepoScores/releasableScore/scoreGrade,projectReleasedScorethe release's snapshot).gradebookimports neitherresultsnorproject.- Routes (
modules/gradebook/routes.ts, contracts inpackages/contracts/src/gradebook.ts):GETandPATCH /app/api/classrooms/:id/gradebook(GradebookStaff; the PATCH publishes or stops the mean),PATCH …/gradebook/columns/:kind/:activityId(weight, counts, position),PUT/DELETE …/columns/:kind/:activityId/marks/:eid(409 grade_existswithoutoverride: true,422 score_above_max), each write answering the table;GET /app/api/student/classrooms/:id/gradebook(GradebookStudent, throughreadableClassroom, the mean key absent unless published). Errors:GradebookErrorCode. Audit:gradebook.mark_set,mark_cleared,column_updated,mean_published,mean_unpublished. Hintgradebook(staff topic and the claimed students'user:topics). - For M5-03b: the staff's table is
staffGradebook(modules/gradebook/table.ts), the format of the cells (grade,a1.0for an absence, empty) isGradebookStaffCell. - For M5-04: staff columns include the ones not released (marks can be set
there, their grades are not shown nor in the mean); a student's columns
are their Grades rows of the classroom plus the released projects; a
student's
withheldcell (policynone) andindicativecell (points, no grade) say why there is no grade. The global/gradespage keeps its own rows (StudentGrades): moving it onto the gradebook is M5-04's call. - As delivered (M5-03b), branch
merge/M5-04-grades-web: GET /app/api/classrooms/:id/gradebook.csv(modules/gradebook/csv.ts), staff only on the same load as the table (outsider 404, student 403, no session 401), the F-RES-02 format: UTF-8 BOM,;, CRLF,email;last_name; first_name;<one column per gradebook column, in the table's order>;mean, one row per claimed student seat (staff seats are never in the table), a grade at one decimal, an absence — derived or marked — writtena1.0(a string, never a number: it must not read as a real 1.0), an empty cell empty, the mean empty when null. A column not released has its header suffixed(unreleased)(as(bonus)is in the results CSV), titles cut at 30 characters like the results'. No audit: the results export audits nothing either. No xlsx.- The CSV conventions moved out of
modules/results/csv.tsintoapps/api/src/csv.ts(csvField,line,grade,points,csvFile,csvFilename,BOM): one writer, the results export re-exports what its tests import.
M5-04 — Web: Grades tabs¶
- Depends on: M5-03.
- Goal: teacher Grades tab (Export primary); the student Grades tab reads the gradebook when enabled.
- Note (2026-10-01): the student tab reuses the global
/gradespage's table (StudentGrades, §5.2), its rows narrowed to the classroom on the server (studentEvaluationRowstakes a classroom id; a classroom route loaded throughreadableClassroom), plus project grades; not a second component, and no filtering in the browser. - As delivered (M5-04), branch
merge/M5-04-grades-web: - Route
classroomGrades(/classrooms/:id/grades) leavespreview: one address, two pages (ClassroomView routeTab="grades"andStudentClassroom tab="grades");ComingSoon.tsxand itssoon.*keys are deleted (it was the last user). Thepreview/CLASSROOM_PAGESmechanism stays inrouter.ts, with no route using it. - Staff (
gradebook/StaffGradebook.tsx): the matrix (sticky student column, mean pinned from@2xl, the card scrolls, never the page), the absencea1.0as a chip of its own ininfo(gradebook/cells.tsx; DESIGN.md › The gradebook), a column not released badged and tinted and out of the mean, Export CSV the page's one primary (a plain link in the header), a menu per cell (absent, a score in a dialog, clear the mark), a menu per column (counts in the mean, the weight in a dialog), the "Students see their mean" switch. A409 grade_existsasks (useConfirm) and resends withoverride: true;403 owner_required,422 score_above_maxand409 classroom_archivedare worded (gbook.refusal.*); an archived classroom draws no menu and a disabled switch. - Student (
gradebook/StudentGradebook.tsx): their own cells fromGET /student/classrooms/:id/gradebook, worded — grade,a1.0, dash, "indicative" under its points, "grade not shared" — and the mean and the weights only when the payload has them. Always a tab of the student's classroom page (the Tabs now always has Activities and Grades). - Decision on the global
/gradespage: it stays onStudentGrades. Its rows carry a status, the feedback link and the pending count, which the gradebook's cells do not (and must not) carry; moving it would mean widening the gradebook payload or losing the page's links. The classroom tab is a separate small list for the same reason (the card's note said "reuse the table"; the payloads differ). Revisit with the mean on the global page. - Query keys under a root of their own,
gradebook(classroomGradebookKey,studentGradebookKey); thegradebookhint names only that root, and theresults,projects,rosterandclassroomshints now reach it too (a release, a rescore, a roster change and an archive move cells). - Mock:
mock/gradebook.tsserves both sides over PRG1-2026 (six columns, the persona's own cells) and PRG1-2024 (archived, read-only), with the server's refusals; checked bycontract.test.ts. Scenesclassroom-grades,-cell-menu,-column-menu,-empty,-student.
M6 — Online workspace and SEB (after the cutover if D09 says so)¶
M6-01 — HS256 and codespace contracts¶
- Depends on: D09. ‖ M6-02, M6-05.
- Goal:
hs256intopackages/domain; codespace contracts (zod) intopackages/contracts; both issuers accepted; optionalsebclaim. - Acceptance: a token signed by classroom's current code verifies (fixture).
- As delivered (branch
merge/M6-01-codespace-contracts): no migration, no route.packages/domain/src/hs256.ts: classroom'ssignHs256/verifyHs256ported on Web Crypto alone (a global, no Node import, so the domain stays platform-free; the comparison iscrypto.subtle.verify, constant time), plusissueras one value or a list, an optionalrequireJti, amissing-jtifailure and amalformedanswer for a non-object header or payload;algis checked before the signature,exp/iatwith a 30 s leeway. Single use of thejtistays the portal's.packages/contracts/src/codespace.ts:LaunchTokenClaims(issinheig-classroom|heig-quiz, optionalseb: {configKey}),ServiceTokenClaims,CodespaceAssignmentSync,CodespaceAssignmentSyncResult, the audiences and the 5 min / 2 min lifetimes. Zod schemas, not classroom's interfaces; the wire shape is unchanged.config.ts:CODESPACE_URLandCODESPACE_LAUNCH_SECRET(>= 32 characters, environment only); a URL without the secret refuses to start in every environment, development included (classroom's rule; the way out is to leave both unset); with neither the feature is off. The fixture inpackages/contracts/src/codespace.test.tswas signed by classroom's ownhs256.ts, run locally. Nothing about a workspace question in an evaluation (a separate ADR).
M6-02 — packages/seb¶
- Depends on: D21. ‖ M6-01.
- Goal: §6.3 point 1; Quiz's
auth/seb.tsmoved onto it. - Acceptance: Moodle vectors and the 201-key vector pass; the Quiz
.sebbytes and Config Key identical before/after (snapshot);seb.db.testroute sweep unchanged. - As delivered (branch
merge/M6-02-packages-seb): no migration, no route; no behaviour change but one:absoluteRequestUrldrops a#fragmentfrom the target (SEB's spec hashes the URL "without Fragment part"), which a browser never sends but a hand-made request could.packages/seb(@quiz/seb, one entry., exporting the card's list: the plist constructors and types,toPlistXml,configKey,buildSebConfig,absoluteRequestUrl,expectedHash,hashesEqual,CONFIG_KEY_HEADER):plist.ts(classroom's typedSebValuetree, render and parse; the parser serves the vectors and reading a file back),configKey.ts(classroom's full port of Moodle's rules:originatorVersionstripped at every depth, empty dictionaries pruned,<data>/<real>/<date>),request.ts(CONFIG_KEY_HEADER,absoluteRequestUrl(publicUrl, target),expectedHash,hashesEqual),sebConfig.ts(buildSebConfig({startUrl, allowedHosts, examKeySalt?}): Quiz's keys in Quiz's order; the start URL's host always first,allowedHostsappended without duplicates;examKeySaltas<data>only when given, never a BEK). Hashing isnode:crypto(synchronous, asqt-code's server half), not Web Crypto: the per-request trust check and the start route stay synchronous; the package is server-side only.absoluteRequestUrlkeeps Quiz's rule (ADR-051), not classroom's: the public origin plus the target as received, never re-encoded throughURL, noHost/X-Forwarded-*branch;hashesEqualtrims and lower-cases both sides (classroom's), where Quiz lower-cased the header only. Vectors inpackages/seb/src/fixtures/(Moodle's five files, provenance);apps/api's copy of the 201-key vector moved there.apps/api/src/auth/seb.tskeeps the routes,sebConfig(startUrl)(allowedHosts: []),configKeyHashMatches,launchConfigKey,configKeyHeaderFor;trust.tsand the test helpers import the header and the hashes from@quiz/seb.auth/seb.snapshot.test.ts+auth/fixtures/launch.seb, committed before the move, pin the file's bytes and its Config Key;seb.db.test.tschanged one import only. Not done (M6-07): the BEK header (x-safeexambrowser-requesthash), project hosts,SEB_EXTRA_ALLOWED_HOSTS, BEK checks, the verifier, the codespace portal's adoption of the package.
M6-03 — Import apps/codespace¶
- Depends on: M6-01.
- Goal:
@quiz/codespace, Node 24, nestedCLAUDE.mdand docs,PLATFORM_URLwith alias; unit tests in CI; classroom ADR-013 already imported as ADR-047 (M0-03). - Acceptance: package build/typecheck/test; the app image contains no
codespace;
invariant-reviewerdoes not flag the sanctioned divergences. - As delivered (branch
merge/M6-03-import-codespace): classroom'sapps/codespaceat classrooma676c8c, as@quiz/codespace. No Quiz module calls it yet (M6-06); nothing deploys it (M6-04). - Package: Node 24 like the other apps (root
@types/node), the roottsconfig.base.json(tests now typechecked),vitest.shared.ts;better-sqlite3added to the rootonlyBuiltDependencies, and dropped from that list inside the app image's build (Dockerfile: the API never loads it, node:24-slim cannot build it); a coverage floor, its report uploaded bycoverage.yml. Dependenciesopenid-client,yaml,octokitand@fastify/formbodydropped with what used them. - On
packages/*only:verifyHs256from@quiz/domain; the local zod copies (classroom/schemas.ts) deleted for@quiz/contracts'CodespaceAssignmentSync,LaunchTokenClaims,isSafeIdand the audiences;CodespaceSessionSummaryandCODESPACE_SESSION_STATESadded to the contracts (the answer ofGET /api/assignments/:id/sessions; the Drizzlesessions.statecolumn reuses the list). Launch and service tokens accepted from both issuers,heig-classroomandheig-quiz; both share theclassroom:subject namespace (safe with UUID ids on both sides, open for M6-04/M6-06: never one secret for both platforms in production). PLATFORM_URLreplacesCLASSROOM_URL, read as an alias when the new name is absent (loadConfig);classroomUrl⇒platformUrlin the session manager.- Relay off (ADR-047, M6-03 amendment (a)):
FORGE_KINDdefaults tonone(push instaging.git+PushEvent, nothing relayed);GITHUB_APP_ID/GITHUB_APP_PRIVATE_KEY_PATHrefused at startup in every environment (auth/config.test.ts);FORGE_KIND=githubis the unconfigured forge only, classroom's App-backedcreateGithubForge(andoctokit) not imported. Quiz's own App on the engine VM: open, an ADR at M6-04/M6-05. - Portal OIDC login removed (amendment (b)):
auth/oidc.ts,auth/plugin.ts,auth/session.ts, the home page and its Start button,/teacher/sessions,COOKIE_SECRET,OIDC_*,SESSION_TTL_HOURS;/is a landing page;onStartis optional inseb/routes.tsand the portal passes none, so/exam/:id/startis not registered; the.sebroute stays.AssignmentSebConfig.startUrlis required (JSON column, no migration).src/seb/moves ontopackages/sebat M6-07. - Pages in English and French (root invariant 1):
src/web/i18n.ts(fr: Record<keyof typeof en, string>,Accept-Language, French by default,<html lang>); the launch refusals, the proxy's and SEB's pages and the workspace causes (WorkspaceBootstrapError.shortCauseis now a key and a repository) go throught(). - Request log:
src/logging.tsmaskstoken=in URLs and writes no header, on every logger ofserver.ts;logging.test.tssearches a real portal's log for a launch token, a service token and the cookies. - CI: the unit suite runs in
test-rest(it needsgit, not Podman); the four integration suites (CODESPACE_INTEGRATION=1) run nowhere. The app image installs and deploys@quiz/apialone;.dockerignoredropsapps/codespace/images. - Inert until M6-04/M6-05:
images/,infra/. The docs are kept, with a Quiz banner on the history ones; French-named docs renamed (setup-workstation,milestone-0,leads,proof-b-manual). - Not imported:
.env(secrets) andvar/(e2e databases and volumes);seed/+db/seed.ts+scripts/seed.ts(classroom's standalone YAML seed, meaningless without the login);scripts/e2e.ts/e2e.sh(built on the login and the seed);deploy/anddocs/deploy.md(classroom's VM recipe: it wrote a configuration this portal refuses and copied classroom's App key). M6-04 writes Quiz's deploy and an end-to-end run against/launchfrom~/heig-classroom. - Open:
classroomStartUrlstill builds classroom's/app/codespace/start/:id(M6-06/M6-07 decide Quiz's).
M6-04 — Codespace CI/CD¶
- Depends on: M6-03.
- Goal: artifact per sha, engine-VM dispatcher shared with the runner,
production deploy guarded by live sessions,
push.shfor bootstrap only; the image supply-chain rule settled. - As delivered (branch
merge/M6-04-codespace-deploy; the repository side only, the VM steps are the owner's, from the runbook). Decisions in ADR-016's M6-04 amendment. - Artifact:
ghcr.io/heig-tin-info/quiz-codespace:<sha>(apps/codespace/Dockerfile, a third entry of the CI'simagematrix), run as the quadletdeploy/quiz-codespace.container; the student image stays built on the VM by hand (images/build.sh). - Two instances,
prodandstaging(deploy/lib.sh), each with its env file (secrets andPORTwritten bybootstrap.sh), data, network (infra/net/common.sh) and Caddy site.CODESPACE_INSTANCE(the one new key) names and labels the session containers; an engine lists only its own (engine/instance.test.ts). The git channel has no0.0.0.0fallback in production or for a named instance (git/bind.test.ts); the deploy replays the network setup first. - Dispatcher
infra/engine/deploy.sh, one per checkout (production/opt/quiz-runner, staging/opt/quiz-engine-staging), scoped per key;infra/engine/lib.shshared by both install steps (pull and retag by sha, untag older sha tags, no global prune); a sha from before M6-04 is refused. CI:deploy-stagingdeployscodespace staging,deploy-codespace-prod(its own job afterdeploy-production)codespace prod, behindCODESPACE_DEPLOY=1. - Guard: a
proddeploy exits 3 while a session container of that instance runs, unless forced (CODESPACE_FORCE_SHA). - Logs: the Caddy site masks
token=and drops the Referer (M6-05's item); the RUNBOOK sets the same filter on Caddy's default logger (reverse_proxyerrors), measured with Caddy 2.10. - Verified locally: the image run against the rootful socket,
smoke.mjsagainst it, the Caddy fragment and default logger with Caddy 2.10, the quadlet through Podman's generator, the nftables table in a user namespace, the dispatcher with stubs, shellcheck. - The staging key's risk (root on the VM hosting production): accepted by the owner, 2026-10-07 (ADR-016's M6-04 amendment).
- Open: once root's
authorized_keysnames the dispatcher, the CI sendsrunner prod <sha> <token>, and the dispatcher's<sha> <token>branch and the forwarderapps/runner/deploy/deploy.shgo (a TODO in the dispatcher); after the switch, drop theCODESPACE_DEPLOYgate fromci.yml;test:integrationruns in no workflow; capacity, slices and the off-VM backup are M6-05's.
M6-05 — Engine VM capacity, hygiene, seccomp¶
- Depends on: D09. ‖ M6-01…04.
- Goal: resize; cgroup slices; host nft
inputdrop policy; mask/launch?tokenin Caddy's log; daily off-VM backup (volumes + SQLite); seccomp convergence (keepptrace);deployment.md§3. - Acceptance: grading stays within
RUNNER_TIMEOUT_MSwith N sessions live; one restore performed; gdb breaks and steps;unshare -rfails. - As delivered (part 1: seccomp) (branch
merge/M6-05-seccomp, #602):apps/codespace/infra/seccomp/codespace.jsonis now the runner's profile plus one rule allowingptrace(gdb); what that profile denies is inapps/runner/README.md.apps/codespace/src/seccomp.test.ts(unit suite) fails on any other difference.images/c-dev/test.sh§ 10:unshare -r truefails, rawunshare(CLONE_NEWUSER)andmount(2)answerENOSYS; § 1: gdb breaks, steps over a line and into a call. Run green on the WSL2 workstation (rootful socket,APPARMOR=); not run: the extension host and terminal in a browser. A session container keeps the filter it was created with until it is created again. Remaining: slices, host nft, Caddy log mask, backups,deployment.md§3; no resize (owner's decision, 2026-10-08). - As delivered (part 2: slices and backup) (#606; repository only, the
VM rollout is the owner's). ADR-016's M6-05 amendment;
deployment.md§3. Slicesinfra/engine/{quiz-runner,codespace}.slice(install_slices),Slice=in the quadlets and the shadow unit;RUNNER_CGROUP_PARENTandCODESPACE_CGROUP_PARENTadd--cgroup-parent(tests inengine.test.ts,config.test.ts,engine/args.test.ts). Backup A:backup-export.sh(forced command, cookie tokens blanked) andscripts/engine-backup/(srv's daily pull, 14 kept); monitoring deferred, checked by hand. Remaining: host nftinputpolicy, the restore drill, the acceptance runs. - As delivered (part 3: host nft) (#613; repository only):
infra/engine/host.nft,nft-apply.sh,nft-check.sh; a deploy only warns on drift.deployment.md§3, The host firewall. Verified locally in a user namespace (stubbed systemd), shellcheck. Remaining, the owner's: the first apply and confirm on the VM, the restore drill, the acceptance runs. - As delivered (rollout) (engine VM, 2026-10-08/09, with the owner's
authorization): slices live,
CODESPACE_MEMORY=768mfor both instances; Caddy maskstoken=; HTTP/3 off on the VM (#649). Restore drill passed, first daily pull 2026-10-08. Host firewall confirmed 2026-10-09 after thenft-check.sh vmfix (#651). Acceptance met under load (a loaded live session beside parallel runner smokes): no OOM and nomemory.highin either slice, worst run 5.3 s againstRUNNER_TIMEOUT_MS30 s.host.nftwithoutudp dport 443re-applied and confirmed 2026-10-09; everynft-check.shcheck green (outside, app, vm with the cross-bridge probes).
M6-06 — Quiz codespace module¶
- Depends on: M6-03, M3-02.
- Goal: config and refusals, sync job, start route, resync, sessions summary, per-teacher grants and quota (admin), audit, en/fr, loaders.
- Acceptance: routes 404 when
CODESPACE_URLis empty; neither token nor BEK in the audit nor any student payload;pnpm smokestart step against a stub portal. - As delivered (branch
merge/M6-06-codespace-module; product owner decisions A–D of 2026-10-07, recorded as the ADR-047 amendment "M6-06" and the F-PROJ-19 amendment). Migration0079_codespace_module. - Schema:
projects.work_mode(freedefault, theprojectmodule's);teacher_grants.codespace_enabled(false) andcodespace_max_active_sessions(2) — ADR-047's names, not the brief'smax_active_sessions, so classroom's column maps one to one at M8;codespace_projects(thecodespacemodule's:synced_at,sync_error,first_launch_at), one row per project the portal heard of, cascade on the project. - Pure rules (
@quiz/domainworkMode.ts):workModeRefusal(owner → grant → frozen → group, in that order),collaboratorPermission(push/pull/none),quotaHolder(creator while owner, else the oldest owner seat, ties by id). - Routes (
modules/codespace/routes.ts, registered only with the App ANDCODESPACE_URL):GET /app/api/projects/:id/workspace(mode,allowed,refusal, last sync),PUT …/workspace/mode(withCourseRoleowner in the loader, then the grant, thensetWorkModeof theprojectmodule underFOR UPDATE),POST …/workspace/sync(202;409 not_online,409 seb_required),GET …/workspace/sessions(the portal's list with a service token,userIdmatched to a claimed seat of the classroom; unreachable isreachable: false),GET /app/codespace/start/:id(the portal'sclassroomStartUrlalready builds that path: only its comment changed). Admin:PATCH /app/api/admin/teachers/:gid/codespaceand acodespacefield onAdminTeacher(null when off). - Start route: a request with an
Authorizationheader → 404; anonymous (and aseb/kiosksession, anonymous there by ADR-027's default deny) → 303 to the sign-in withnext; impersonation → 404;findStudentProjectViewwith a claimed seat (a staff seat's test repository included, ADR-077) else 404; then, under a share lock on the project (startWorkspace, the decision the pureworkspaceStartRefusalof@quiz/domain), refusals sent back as/projects/:id?workspace=not_online|seb_required|not_accepted|closed(closed: the effective deadline passed or the classroom archived); thenfirst_launch_atset — never by a staff seat (ADR-077: a teacher testing the project freezes nothing) —, a launch token (iss heig-quiz, 5 min, randomjti, nosebclaim), auditcodespace.launch_issuedwith{jti, mode}, 303 to${CODESPACE_URL}/launch?token=. The quota is the portal's 429 (Quiz cannot count live workspaces). - Sync:
codespace.syncqueue (retry 5, backoff), sent after the mode, a patch, a publication (route and ticker); inline when there is no queue, its failure swallowed. Onlyonlineprojects with a built distribution are sent (seeded from the distribution, never the source);online_sebis not sent — the portal refuses an exam without Browser Exam Keys (M6-07).deadlineAtis the project's: a repository's own later deadline is not sent (the start route judges the student's effective deadline itself). Auditcodespace.synced,codespace.sync_requested,codespace.work_mode,teacher.codespace_grant. - Provisioning (ADR-047 §2):
provisionStudentRepo({permission})—pullonline, no invitation underonline_seb(invitation_statusnone; a second Accept answers the repository and invites nobody;inviteAccountrefuses409 not_invitablefor it). An invitation already sent keeps its permission when the mode changes before the first launch. Group mode and an online mode exclude each other (409 work_mode_group, both ways). - Grant lookup: the verified addresses only (
knownEmails, plus the sign-in address when verified), the most permissive row; the administrator holds no row and is not granted (superseded 2026-10-08: an administrator is always granted, the default quota at least, ADR-047). - Single sources:
WORK_MODES,WORK_MODE_REFUSALS,WORKSPACE_START_REFUSALSandsyncsToPortallive in@quiz/domain(workMode.ts), re-exported by the contracts. The staff's sessions carry no address from the portal: an unmatched workspace is "not in this classroom". - Web:
project/ProjectWorkspace.tsxon the staff's project page (absent on the 404), the student's Open workspace (a plain link to the start route) and the refusal alert (?workspace=), the admin's switch and quota per grant; en/fr; mock?codespace=1(mock/codespace.ts, checked bycontract.test.ts); scenesproject-workspace*,student-project-workspace*,admin-workspace-grants. - Smoke:
pnpm smokegains "online workspace start route" — off, the 404; on, the anonymous 303 and a student's 404 on a project nobody holds. A launch against a stub portal is not reachable over HTTP alone (a project needs GitHub to provision its repository, and no seed makes one):codespace.db.test.tslaunches against a stub portal instead. - Open: with the portal's forge off, a workspace can only be seeded
from a PUBLIC distribution repository; a private one (the default)
syncs but cannot seed, until the decision on Quiz's App on the engine
VM (M6-04/M6-05). The portal's shared
classroom:subject namespace for both issuers (one issuer per portal in production, M6-04); the BEKs, the.seb,online_seb's sync and launch (M6-07).
M6-10 — Git relay through Quiz-issued scoped tokens (ADR-078)¶
- Depends on: M6-04, M6-06.
- Goal: the portal seeds from private repositories and relays pushes
to GitHub without holding an App credential (ADR-078, option B).
Quiz: contracts
GitTokenRequestClaims/GitTokenGrant(audienceheig-quiz-git-token) andRelayHeadsClaims(audienceheig-quiz-relay-heads); puregitTokenRefusalin@quiz/domain, built onworkspaceStartRefusal, open until the effective deadline plus the project's grace;codespace_launches(per project and user, written by the start route) andcodespace_relays(one row per declared (repository, sha));POST /app/codespace/git-tokenminting a token on one repository id (contents: writefor the user's own live repository,readfor the distribution repository of anonline_sebproject only),useUntil,Cache-Control: no-store, auditcodespace.git_token_issued(no token);POST /app/codespace/relay-heads(204, records the declared heads);pushedByreads an App push as the student's only when its head matches a declared relay of that repository; the Caddy fragment restricts both paths to the engine VM. Portal:FORGE_KIND=quiz(the default withPLATFORM_URLand the secret;forgejoandgithubrefused in production); the per-repository in-memory cache (refresh 10 min beforeexpiresAt, hard stop atuseUntilwith no new request, single flight, dropped on 401/403), revocation atuseUntil; declare the heads, then push;refspecForwithout force (<sha>:<ref>, no deletion relayed), and on a non-fast-forward rejection a fetch of GitHub's head intostaging.gitand a terminalrejectedPushEventshown to the student (status bar) and the staff (workspace list); Quiz's refusals on the slow backoff; the URL-scopedextraheader. Docs: rootCLAUDE.mdinvariant 15 gains ADR-078's sentence;apps/codespace/CLAUDE.mdandsrc/git/README.mdname thequizforge. - Known race, out of scope: the App inserts its
bot_commitsrow after it moves the ref inmodules/project/protection.ts(line 246, the revert) andmodules/project/jobs.ts(line 438, the deadline commit), so a webhook can arrive before the row. ADR-078's attribution no longer depends on that order; the existing paths keep the race until a fix of their own. - Settled (product owner, 2026-10-07): ADR-078 §7 confirmed — tokens and declarations until the effective deadline plus the grace.
- Acceptance: a token or a declaration is refused (and no token
minted) for another user's repository, a repository of another project,
a distribution repository of an
onlineproject, a project never launched by that user, after the effective deadline plus the grace, on a staff-locked repository, in an archived classroom, with another audience or an expired request; a write grant'suseUntilnever passes the effective deadline plus the grace, and the portal never uses a token past it; the minted token is found in no Quiz log line, audit row or portal log,last_error, SQLite row, argv or student container environment (searched); a declared relayed push is the student's last commit and its grading run counts, while an undeclared App push (a revert, a deadline commit, a sync) does not; the relay never force-pushes (a test with a commit of the App ahead on GitHub: the push is rejected, nothing is erased,staging.gitgets GitHub's head, thePushEventisrejected, the student's commit still in the workspace clone and thePushEvent); on the staging App, a forced push made with an App installation token is refused byhgc-protecton the default branch, and the same push on a non-default branch is tried and its outcome recorded (the ruleset covers~DEFAULT_BRANCHonly, ADR-078 §9); a push in the last seconds before the deadline is relayed and received; a relay pending after the grace resumes once the repository's deadline is extended; integration against a stub Quiz and a GitHub test organization of the staging App. - As delivered (branch
merge/M6-10-git-relay; migration0082_codespace_git_relay; the portal's schema needs none —rejectedis a value of a SQLite text enum): - Contracts (
codespace.ts):GitTokenRequestClaims,RelayHeadsClaims(1–50 heads, a ref and a 40/64-hex sha),GitTokenGrant,GitTokenError, the audiences, paths andPORTAL_ISSUER;CodespaceSessionSummary.rejectedPush(default null, wire-compatible). Domain:gitTokenRefusalandrelayClosesAt(workMode.ts), onworkspaceStartRefusalread at the deadline plus the grace. - Quiz:
codespace_launches(written by the start route, staff seats included) andcodespace_relays;modules/codespace/relay.ts(issueGitToken,declareRelayHeads) behindPOST /app/codespace/git-tokenandPOST /app/codespace/relay-heads(no-store, refusals logged with code andjti, never audited);mintRepositoryToken(github/app.ts, the one place: one repository id,contentsonly); auditcodespace.git_token_issued;pushAuthor(modules/github/deliveries.ts) used by the intake's receipt and the project's push handler; the Caddy fragments (Caddyfile,Caddyfile.staging) answer both paths with a 404 except from the engine VM (2.29.38.213). - Portal:
FORGE_KIND=quiz(src/git/quizForge.ts) and its defaults/refusals (auth/config.ts);Forge.authorization(repo, owner),declareHeads,invalidate,forget;ForgeRefusedError;gitAuthEnv(authorization, url), always scoped to the remote's origin (none for a non-HTTP remote); the relay without force,parseRejections, the fetch-back intostaging.git, the terminalrejectedstate;GET /git/<session>/push-status; the status bar's third item (images/c-dev/extension); the sessions summary'srejectedPush, shown in Quiz's workspace list (en/fr). - Decisions beyond the ADR (for review): (1) a read grant on the
distribution also needs the user's own live repository, and is closed
with it (one openness rule for both grants); (2) a declared head that
is one of the App's
bot_commitsstays the App's — a restore or sync is never the student's, declared or not; (3) a branch deletion's rows takerejected(DELETION_NOT_RELAYED), so they never sit pending; (4) a[remote rejected]ref (a workflow file withoutworkflows, a ruleset) isrejectedwithout a fetch-back; the refs--atomictook down with it are retried at once; (5) a refresh 10 minutes beforeexpiresAtreplaces the old token without revoking it (a push may still use it; it expires within the margin) — revocation happens atuseUntil(a timer, and lazily) and onforget; (6) the student sees a rejection through the git channel (push-status, the workspace'sorigin), so no container environment key is added; (7)FORGE_KINDdefaults toquizonly whenPLATFORM_URL(orCLASSROOM_URL) is given in the environment — its built-in localhost default does not count —, andquizwithoutCODESPACE_LAUNCH_SECRETis refused in every environment; (8)deploy/env.examplenow writesFORGE_KIND=quiz; an instance's existing env file keepsnoneuntil edited (bootstrap never rewrites it). Two failures of the integration-onlyrelay.test.tsthat predate M6-10 (a message renamed at M6-03, a clock in the past) are fixed. (9) A staff-locked repository still opens a workspace (the start route does not read the staff lock, ADR-047), but Quiz refuses its tokens and declarations (closed, ADR-078 §2 check 5): its pushes staypendinginstaging.gituntil the staff unlock it — chosen by the ADR, recorded here. (10) After review: the closing rule is one pureworkspaceClosedshared by the start route andgitTokenRefusal; request ids are uuids in the contract (a non-uuid is a401); oneauthorizeCallprologue serves both routes; onebearerOfparser (auth/plugin.ts); a request in flight when its workspace is forgotten keeps nothing, and its token is revoked once its single attempt settles (Forge.settle); the quiz forge fails closed (ForgeOriginError) on any remote whose origin is nothttps://github.com. - Tests: domain
workMode.test.ts(grant, refusals, grace, extension, staff lock); contractscodespace.test.ts; apimodules/codespace/relay.db.test.ts(12: scope and body of the mint,useUntil, every refusal with nothing minted, 401 cases, 503, the declaration, the attribution positive and negative, a declaredbot_commitshead, the protected-file restore, the token in no log or audit row); portalquizForge.test.ts(cache, margin, hard stop without a request, single flight, invalidate, forget and revocation, refusals vs outages, signed claims),relayNoForce.test.ts(refspec, porcelain, the non-fast-forward on bare-repo fixtures end to end, no deletion, a refused declaration, the token in no argv, file, SQLite,last_erroror log, scoped to github.com, dropped on 401),config.test.ts,httpBackend.test.ts(push-status),classroom/routes.test.ts(rejectedPush); webProjectWorkspace.test.tsx. - Follow-ups:
codespace_relaysrows are not purged with their project (they cascade with their user only; no secret in them); the daily reconciliation's head refresh (reconcile.ts) does not read the relays (the webhook path attributes; the reconciliation still needs a person as author and committer); thebot_commitsrace noted above is unchanged; a push of the App costs two lookups ofpushAuthor(the intake's receipt, then the project's push handler) — one could be carried by the receipt. - Manual checks on staging, for the orchestrator (they need the
staging App, its test organization and the engine VM):
caddy validatethe two fragments; from the app VM itself,curl -X POST https://quiz.dev.chevallier.io/app/codespace/git-token→ 404; from the engine VM → 401 (no token).- Set
FORGE_KIND=quiz(or remove the line) in/etc/quiz-codespace/staging/env, restart the instance; launch a workspace on anonlineproject whose student repository is private: the workspace seeds from it; push from it: relayed, received as the student's (last commit,push_receipts.is_botfalse), its grading run counted; the audit showscodespace.git_token_issuedwithout the token. - An
online_sebproject with a private distribution: the exam workspace seeds (read grant). - With an App installation token of the staging App scoped to one
repository (
contents: write), trygit push --forceof a rewritten history to the default branch:hgc-protectmust refuse it. Try the same on a non-default branch and record the outcome (expected: accepted, the ruleset covers~DEFAULT_BRANCHonly, ADR-078 §9). - Commit to the repository with the App (a protected-file restore),
then push from the workspace without pulling: the
PushEventisrejected, the status bar shows Push refused by GitHub, the staff's workspace list shows Rejected by GitHub (main); after a pull and a push, both clear and the push is relayed. - A push in the last seconds before the deadline is relayed and
received late or on time by the receipt; after the grace a pending
relay stays
pending(409 closed), and resumes once the repository's own deadline is extended.
M7 — Finishing¶
M7-01 — Palette, help, tours¶
- Depends on: the screens they point to.
M7-02 — User guide¶
- Goal: guide pages for projects, the journal, GitHub setup (teachers, students); screenshots in both themes.
- As delivered (#530, #528): a teacher guide (GitHub setup,
projects, groups, journal). b
docs/guide/student-projects.md(link, accept, invitation, push, score, deadline, release, notifications, forming a group, journal), a pointer section indocs/guide/students.md, thestudent-projectandstudent-groupshelp topics (en and fr), and mock screenshots in both themes underdocs/assets/screenshots/student-*. Checked against main afterwards: a closed group set shows a student only their own group (ADR-070, decision 8), now stated in the guide.
M8 — Migration and cutover¶
M8-01 — Import script complete¶
- Split (2026-10-05, orchestrator, product owner's decisions of the same
day): one task, four PRs, each a part of the same script. a the
frame: pre-flight, parity report, re-import semantics, the legacy audit
(delivered, below); b projects, checkpoints, repositories, grade runs,
ledgers, receipts, bot commits, reverts, notification preferences; c
groups → group sets (ADR-070); d journals and webhook deliveries. b, c
and d each add a
steps-<entity>.tsand one line inregistry.ts,tallywhat they carry, applysyncOwnedto the rows they create, and extend the fixture; the parity checks that need GitHub register asgithubBound. The M8-01 acceptance (dry-run on the fixture and on a staging copy of a dump, parity report clean) belongs to the last of them. - Staff-seat repositories (ADR-077, product owner 2026-10-06, its Q8): with M3-14c a repository held by a user who holds a STAFF seat of the classroom is that teacher's test repository: visible to them, badged "Teacher" on the staff page, counted nowhere. The teachers' repositories heig-classroom let them accept are imported with the staff flag kept, so they would appear so. The dry run on the production dump must COUNT them (per classroom) and report the number; the product owner decides then whether they are imported. No code before that decision.
- As delivered (a) (branch
merge/M8-01a-import-frame; migration0076_legacy_classroom_audit): Q:apps/api/scripts/import-classroom/:ctx.ts(the context, the report helpers,remember,tally/tallyMapped,syncOwned),registry.ts(the ONE ordered list of steps and checks),preflight.ts(pure, over the snapshot),report.ts(the report type,formatReport),steps-audit.ts;source.tsnow reads assignments, student repositories, grade-run ids, groups, members and the wholeaudit_log, plus the source's activity andpgboss.job.- Re-import (D26 addendum 2026-10-05): a row the import created is
overwritten from classroom unless Quiz modified it since (hash stored in
id_map:target_table,imported_hash,source_hash); kept and listed (reimport.kept) otherwise. Applied to created users and created enrollment lines; a step that creates rows builds anOwnedRowand callsremember(…, owned)thensyncOwnedon the next run. A row without a baseline (written by M1-06's version) adopts one when it equals classroom's, is kept otherwise. - Decisions (D08/D11/D22/D26 addenda):
--missing-students reportand--assistants skipare the defaults, listed inlists.*on every run;--applyno longer waits for them; organizations and links never imported (red line if their count, or courses' or classrooms', moves). - Pre-flight: STATE checks (source stopped: no task or webhook in the
last 10 minutes and none running;
pgboss.jobempty and readable; no unprocessed webhook; no published assignment of a mapped classroom with deadline or grace inside--window-hours(24), overdue and unapplied, or applied and unfrozen) refuse only--final; before it they read "would refuse --final" (the first import runs on a live classroom). DATA checks (no dangling or foreign*_grade_run_id; group, member and group repository agree on the assignment and the roster) refuse every--apply. What no query can check is the report'srunbooklist, for M8-05. - Parity report (
report.parity,--report-json): rows per tablesource/carried/leftOut/missing; findings;redLines;notRun. A steptallys what it meant to carry;missingnot zero, a protected table that moved or aredfinding is a red line: exit status 3, and an--applythat finds one inside its transaction rolls back (outcomered_lines).githubBoundchecks run after the commit through a read transaction, "not run" in a dry run; a red line found there is reported, the data stays. - Legacy audit:
legacy_classroom_audit_log(source_idunique,actor_user_idbest-effort,source_actor_user_idalways), insert-only, no route. - Left for c and the runbook: everything not above and not in (b) or (d); the source state a database cannot show (process stopped, classroom's App silent on repositories, backups) is M8-05's.
- As delivered (d) (branch
merge/M8-01d-import-journals; no migration, every target column existed): Q:apps/api/scripts/import-classroom/steps-journals.tsandsteps-webhooks.ts, registered before the legacy audit (journals, then deliveries);source.tsreadsjournalAttachments(classroom_journalsjoined tojournals; never pages nor assets, I65) and the wholewebhook_deliveries;ctx.tsgainsCtx.now(the run's clock) andOwnedRow.idColumn(a table keyed on something else thanid: a journal row is keyed on its classroom, and the id map's target id is the Quiz classroom id).- Journals: one
classroom_journalsrow per attachment of a mapped classroom (modegithub, repository id, full name, branch, folder),sync_status = pending,created_bythe remappedattached_byelse the--actor,created_at=attached_at; one journal read by several classrooms is several rows. Left out and listed (findings.journals, tallied as left out): a journal of ANOTHER organization than its classroom's (Quiz reads through the classroom's own installation, which cannot see that repository), a journal with no repository id yet (classroom_journals_mode_ck), a Quiz classroom that already has a journal elsewhere (Quiz's kept; the same repository and branch is merged), no author to record. A journal whose organization has no App acting (not installed, suspended, not active:actsOn) is imported PENDING and reported: the row is inert and heals with the first Refresh or push. Re-import:syncOwnedover mode, repository id, full name, branch and folder; an overwritten row goes back topending,sync_errorcleared,version+ 1 (fix J2). A rename the ingestion followed counts as a Quiz change and the row is kept. - Webhook deliveries: those received in the 30 days before the run's
clock, copied insert-only into
webhook_deliveries(same key, GitHub's delivery id), all recorded as PROCESSED (an unprocessed one is stamped with the import's time, listed infindings.webhooks: Quiz'sreconcile.deliverieswould otherwise replay classroom's payloads through its own handlers). Older rows are counted, not copied. - Parity:
classroom_journalsandwebhook_deliveriestallied; checkjournals re-ingested(githubBound): every imported journal read back after the commit,okcounted,pendingawarn,errora red line with its code; "not run" in a dry run. Nothing in Quiz sweeps apendingjournal row (a push or a Refresh enqueues the ingestion), so the registry'safterCommithook (ingestImportedJournals, never in a dry run, outside any transaction, before the checks) calls the journal module's owningestJournalfor every row the run created or overwrote, sequentially, with the target database and no queue (ImportOptions.ingestJournal, wired by the CLI when Quiz's App is configured; without it the rows staypending, awarn). A second run ingests nothing. - As delivered (b) (branch
merge/M8-01b-import-projects; no migration — every target column existed): Q:apps/api/scripts/import-classroom/:steps-projects.ts(projects, checkpoints, reminders, and the sharedrepoLeftOut,carryOwned),steps-repos.ts(repositories + sync pull requests, runs, bot commits, ledger, restores, receipts),checks-projects.ts(the parity checks), registered inregistry.tsbetween the enrollments and the legacy audit;source.tsnow reads the milestones, every column of assignments and repositories, the runs, receipts, bot commits, ledger and restores;Ctx.nowis the import's clock;reachedUsers(run.ts) also reaches the student of a repository whose roster line is gone, and the staff who graded or released. Fixture part 3 andscripts/import-classroom-projects.db.test.ts(16).- Rows: ids kept (the id map holds
assignments,assignment_milestones,student_reposfor M8-02); a project and a repository the import created follow the re-import rule (syncOwned), runs, bot commits, ledger, restores, receipts and sync pull requests are insert-only (a present row is left alone, whoever wrote it; parity reads them by natural key). A Quiz project holding the same slug, distribution repository or GitHub repository leaves the source row uncarried: a red line, never an overwrite (D26 addendum of 2026-10-02). - Mapping (M3-01 and following notes):
org_id= the mapped classroom's link,created_by= classroom's owner else the--actor,grading_scalescore_is_grade,squashed_*→distribution_*,grades_validated_*→ release,source_headsnull,group_set_idnull (c fills it), the codespace columns refused by the pre-flight (work-mode, every--apply). The freeze is the repositories': each repository of an applied project takesdeadline_applied_at, of a frozen onefrozen_at;deadline_committed_atis read from the firstdeadlinebot commit (acommitproject applied without one is listed: Quiz's job will make it);locked_at,ruleset_id, the slots (llm_grade_run_id→review_grade_run_id) as they are;teacher_maxnull. A released project's repositories carry the final score at the import (resolveFinalScore) asreleased_points/released_max, and the teacher's comment asreleased_comment. Runs:llm→review,to_verifyfalse, no parse detail; restores keephead_sha,covered_sha,branchnull.sync_pr_number/sync_pr_state→ theproject_sync_prsrow of the default branch (stateopenwhen classroom had none,updated_atthe import's clock). - The ledger (M3-05b note):
milestone→checkpoint; an unconfirmed row (dispatched_atnull) kept as it is and listed; a confirmed syntheticdeadlinerow (shathe frozen run's head,dispatched_atthe assignment'sllm_dispatched_at) for each repository with a frozen run and nodeadlinerow of a project gradedautowhosellm_dispatched_atis set (counted asgrade_dispatches (synthetic)). None for a project gradednone: Quiz would never dispatch it, the row would only claim a review. - The reminder (M3-09b note): classroom's marker is copied; with none, a project whose deadline is past or within 24 h of the import is marked at the import time; only while Quiz's own marker is null (never undone), and the column is outside the re-import hash. A repository's own marker is for an own deadline, which classroom has none.
- Left out on purpose, listed in the parity report: group
repositories and every row under them (one place,
repoLeftOut; M8-01c lifted it, see "As delivered (c)"), a repository whose student is not imported (a development account), receipts of a repository without a GitHub id. Individual repositories inside a group project are carried and listed, live or not (isLiveIndividualRepo, the M3-08 note): a live one holds its student out of the group's repository, as in Quiz. Notification preferences are not carried (notifyPrefsis not ported, M3-09b); the M1-06 note says so. - Parity checks (§2.5, over what was carried, a repository the re-import
kept excluded from the figures it would fail): repositories per project,
sum(teacher_points)per project, count of frozen grades per project, every grade-run link resolves to a run of its repository, the latest push receipt per repository equals the source's (a later Quiz receipt is a warning). Each answersinfowith its figures when it passes. - What d and c inherit:
Ctx.now;reachedUsers; theprojectsfindings section;repoLeftOutand the tally ofstudent_repos/grade_runs/bot_commits/grade_dispatches/reverts/push_receiptsto widen for group repositories; theproject_groupscopy,groups_stopped_atandgroup_set_idare c's. - As delivered (c) (branch
merge/M8-01c-import-groups; no migration): - Where:
Q:apps/api/scripts/import-classroom/steps-groups.ts(sets, copies, members, stops, invited accounts) andchecks-groups.ts, registered BEFORE "project repositories" (a repository'sgroup_idnames a copy group); the group repositories and their children go throughsteps-repos.ts(repoLeftOutlifted, tallygroup repositories, therepositories per projectcheck also comparesgroup_id). Fixture part 4 (Pair Gamma); tests inscripts/import-classroom-projects.db.test.ts. - Decisions: one
group_setsrow per group project, named after it (ADR-070 §10), so a classroom that reused its groups gets one set per assignment; ids kept in every table, the id map'sassignment_groups/assignment_group_membersbeing the COPY's (the set's are… (set));projects.group_set_idset only for a set this run created, never moved; a copy this run creates is stopped throughstopProjectsat the run's clock (the source has no such instant) when QUIZ's project is past its deadline, applied or archived; never on a later run (Quiz's ticker owns the stops, a staff resync lifts them), andstopped_at/departing_atare not owned columns. A set, group or roster line Quiz deleted since an earlier run is not recreated and nothing is placed under it (listed). Members missing from the Quiz roster are reported, never placed (--missing-students report); staff seats never. A group repository whose creator is not imported is recorded under the first member who is, else it is a red line. - Access:
project_repo_accessis filled from Quiz's ownrepoMembersfor each live carried group repository (invited_atthe later of the repository'saccepted_atand the member'sadded_at), insert-only, never a revocation, never a call to GitHub. - Parity: tallies
assignment_groups,assignment_group_members,assignments (group set),group repositories,group repository access; checksgroups per project,members per group(the group repositories joinrepositories per project; a row an earlier run carried is exempt: a set is live in Quiz). None isgithubBound. With a, b, c and d delivered, the M8-01 acceptance is the dry-run on the fixture (clean) and on a staging copy of a dump. -
Follow-ups (code review of #550, not blocking): scope the stop of an already-followed copy to the groups created in this run (a new group in a past project re-stops the groups staff re-opened); test
staffLinesbefore the "not on the roster" branch ofcarryMembers; exempt repositories carried before this run from the group-id comparison ofrepositories per project; buildaddedAtfrom the members the import actually placed; batchrepoMembersper project (quadratic on a large project); drop the unusedctxofperParent; one source for the creator of a set (creatorOf). -
Depends on: every schema task (M2-01, M3-01, M4-01, M5-03, M6-06 if in scope), D11.
- Goal: the whole order of §2.5, the verification report, the legacy audit table, pre-flight checks — extending M1-06's script, which already imports the people, the rosters and the GitHub account links. The cutover waits for this task (D08 addendum): M1-06 alone switches nothing.
- Acceptance: dry-run on the synthetic fixture and on a staging copy of a production dump: parity report clean.
- From M2-02's review: an organization imported twice (a null-id row
and an id row with the same login) must be merged by the import, not left
to
retireLoginHolders. - From M3-01 (the project steps moved here, 2026-10-01): ids kept,
ON CONFLICT DO NOTHING, in FK order.assignments→projects(classroom_idmapped;org_id= the mapped classroom'sgithub_classroom_links.org_id;squashed_*→distribution_*;llm_dispatched_atdropped (M3-05b: the column is gone, migration0060; see the note below);grades_validated_at/by→released_at/by;created_by= the remapped classroom owner;grading_scale={kind: "score_is_grade"}, heig-classroom's own reading — a score out of 6 is the grade, any other maximum linear (product owner, 2026-10-02: released grades do not move at the import); the codespace columns andwork_modedropped, a non-freerow refused);assignment_milestones→project_checkpoints; groups and members (enrollment_idremapped);student_repos→project_repos(user_id,teacher_graded_byremapped,llm_grade_run_id→review_grade_run_id,accepted_atcopied — no default; for a released project,released_points/released_max= the final score at the import, so nothing reads "changed after release");grade_runs→project_grade_runs(grade_points/max→points/max,kindllm→review);bot_commits,grade_dispatches(triggermilestone→checkpoint,milestone_id→checkpoint_id),reverts(student_repo_id→repo_id). - From M3-04 (orchestrator, 2026-10-02): Quiz counts two bots only,
its own App and
github-actions[bot]: heig-classroom's App must stop acting on a repository once it is imported (no restore, deadline commit nor sync of its own), or its pushes would be a person's to Quiz — checked by the restores and counted as student commits. Importedrevertsrows keephead_shanull (the column is new, migration0057), and importedproject_grade_runstaketo_verify = false,parse_detailnull. - From M3-05b (2026-10-02): Quiz derives "the final review was asked"
from the
grade_dispatchesledger alone (projects.review_dispatched_atis dropped), and its ticker dispatches agrade-finalto EVERY live repository frozen with a frozen run and nodeadlineledger row, of a project gradedauto. So the import must leave no such repository it does not mean to review again: for an assignment whosellm_dispatched_atis set, a repository with a frozen run and nodeadlinerow (a row lost, or older than the ledger) gets a confirmed synthetic row (sha= its frozen run's head,dispatched_at=llm_dispatched_at), counted in the report. heig-classroom's ledger rows left unconfirmed (dispatched_atnull, which it would have retried) are imported as they are: Quiz never sends them again, its staff see them "not confirmed". Likewise a milestone already dispatched keeps itsdispatched_at(project_checkpoints), or it fires again. - From M3-09b (2026-10-04): the day-before reminder is claimed on
projects.reminder_sent_atandproject_repos.reminder_sent_at(own deadline), both null = owed; the scan skips a window under a day onstart_at, nothing else. The import must set them to the import time for every imported project or repository whose effective deadline lies within 24 h of the import (and may for any deadline already past), or the firstprojectTickreminds every student of those projects at once; a deadline further ahead is legitimately reminded later and may stay null. heig-classroom's own marker, when one exists, is copied as it is. An imported organization keepssuspended_atnull unless heig-classroom knew it suspended.
M8-02 — Legacy URL resolver¶
- Depends on: M8-01 (id map), M3-12, M4-04.
- Goal:
/legacy/classroom/*for every row of §6.6. - Acceptance: a table-driven test per row; targets load under
staffAccess. - As delivered (branch
merge/M8-02-legacy-urls): P:domain/legacyClassroom.ts(pure,legacyRule(path): every row of §6.6 as a rule, malformed ids asnot_found),P:contracts/legacy.ts(LegacyClassroomParams,LegacyGone); journal paths are vetted by the contract'ssafeJournalPath/encodeJournalPath,Q:modules/legacy/{routes,service}.ts(GET /legacy/classroom/*, outside/app/api: a browser navigates to it),legacy.db.test.ts(one 302, one 410, then who reaches a target; the per-row table is the domain test).- Fixed rows answer the same to everyone: 302 (
/settings,/,/admin; a query is dropped, the e-mail unsubscribe included) or 410{ error: "moved", to: "/" }for the dead APIs. A path no row names falls to/, the last line of the Caddy fragment.POST /webhooks/githubis not routed here (Caddy answers it, a POST never reaches/legacy); the GET of the same path is a 410. - Entity rows (classroom, project, groups, journal, start): the old id
goes through
import_classroom.id_map(classrooms,assignments,users) and the target is LOADED under the caller's own portal session by the guards' finders (findReadableClassroom,findAccessibleProject=staffAccess,findStudentProjectView, the rule of the page the link lands on): staff or a seated student. An unmapped, malformed, absent or unreachable target is the same 404 (the SPA's own not-found page for a navigation,reply.callNotFound()); an impersonation is nobody. Nobody signed in (aseborkiosksession, a bearer token: the same) gets a 302 to/app/auth/login?next=the same URL, identical for a real and a made-up id. The avatar answers 410 for every reason, as the table says. - The import now writes the classroom map:
importClassroomMap(steps.ts, first step ofregistry.ts) remembers each carried classroom asclassrooms(old id, Quiz classroom,merged). Before it the id map held no classroom row but the journals' one. - Targets:
/classrooms/:id,/classrooms/:id/journal[/<path>](the page path is vetted by the contract'ssafeJournalPath, an unsafe one lands on the journal home, and never checked against the pages: a page's existence is not revealed),/projects/:id, for the groups the project's set (/classrooms/:id/groups/:setId) or the classroom's sets, staff only (a student gets the project page)./app/codespace/start/:aidleads to the project page: Quiz has no codespace start route before M6-06/M6-07. - For M8-03: the Caddy fragment must send every human-clicked path,
/app/api/users/:uid/avatarincluded (the@gone /app/api/*matcher would swallow it), to/legacy/classroom{uri}; Caddy answers onlyPOST /webhooks/githubitself.importClassroomMapfollows a remap: a later run that maps a classroom elsewhere updates the row and notes it. - Shapes: the table names
/classrooms/:id,.../assignments/:aid,.../assignments/:aid/groupsand.../journal/<path>only; any other suffix under/classrooms/:id/is no row and falls to/(the fragment's last line), not silently to the classroom. For an assignment the path's classroom must be one the import carried (any mapping), else the 404; it is not matched against the project's classroom, because a remap moves the classroom and leaves the projects where they are. The access loaded on the project is what protects it. A classroom the mapping later drops loses its id-map row (and its links 404). Identity-dependent answers carryCache-Control: no-store. A loginnextover 1500 encoded characters is dropped (the stash cookie is signed and limited to 4 KB). - Deviation, codespace start:
/app/codespace/start/:aidgoes to/projects/:id, not to a Quiz "project start route" (none exists before M6-06/M6-07); revisit then. -
Not done (needs the Caddy fragment or a product decision): the HMAC-honouring of an old unsubscribe link (the row allows a plain redirect; none is implemented, the secret is not carried); the
Locationis relative (/settings), Caddy'sredirto the Quiz host keeps the browser onquiz.chevallier.io. -
Follow-ups (code review of #547, not blocking): drop the path-classroom id check of the project and groups rules (a dropped classroom's row is deleted, so its still-reachable projects 404; the loaded access already protects them); run
legacyRuleon the raw path or per decoded segment (%2Fin a segment is split before matching); a remap should also moveclassroom_journals; check the status of the SPA not-found page (404, not 200); share one predicate betweenseesAvatarandfindVisibleAvatar; the remap note should name the previous target.
M8-03 — Caddy fragments¶
- Goal:
infra/caddy/classroom-maintenance.caddy,classroom-redirect.caddy, a curl matrix script. M8-03b, a read-only flag in classroom (the one change made in the classroom repository), is built only past the threshold that10-cutover-runbook.md§0 sets: a freeze from C1 to C8 over 2 h, measured by M8-06. - Acceptance:
caddy validate; the script asserts status andLocationof every pattern. - As delivered (branch
merge/M8-03-caddy-fragments): infra/caddy/classroom-maintenance.caddy(C1),infra/caddy/classroom-redirect.caddy(C6),infra/caddy/check-classroom-redirects.sh [--maintenance] [--resolve HOST:PORT:ADDR] BASE_URL(curl >= 7.84).- The redirect fragment answers only the 410 rows (any method on
/webhooks/github; the avatar excluded in the matcher) and sends every other path to/legacy/classroom<path>with one 302:legacyRuleowns the human rows. The query is dropped for every row (classroom's OAuthcode, unsubscribe tokens); the path keeps its percent-encoding ({path}would decode it, so the path is cut from the raw{uri}byvars_regexp). The site address{$CLASSROOM_SITE:classroom.chevallier.io}is unset in production,http://:portlocally. M8-03b not built. - Validation (Caddy 2.10.2):
caddy validate --adapter caddyfile --config <fragment>andcaddy fmt --diffon both;CLASSROOM_SITE=http://:18480 caddy run --adapter caddyfile --config infra/caddy/classroom-redirect.caddy, theninfra/caddy/check-classroom-redirects.sh http://127.0.0.1:18480; the same with the maintenance fragment and--maintenance. Each mode fails against the other fragment.
M8-04 — Codespace identity remap (if M6 is in scope)¶
- Acceptance: on a copy of the portal DB and volumes, one real session resumes with its files.
- Dropped (product owner, 2026-10-09): classroom's portal never served
a real class, and it has been stopped and disabled since M6-04 (its
SQLite and volumes are archived in
/root/classroom-codespaceon the engine VM). Quiz's portal started empty, so there is nothing to remap. Step C4 of the runbook says so.
M8-05 — Cutover runbook¶
- Goal:
docs/development/deployment.mdgains the cutover chapter (§6.5, §6.7): T0 checklist, commands, rollback table, App steps, secrets, go/no-go criteria. - As delivered (branch
merge/M8-05-cutover-runbook, #653):10-cutover-runbook.md. It replaces §6.5 B–E and §6.7. What departs from the card and the plan: - the runbook lives in
docs/merge/, not indeployment.md, because it is a one-off operation; - the production image could not run the import, and O6 makes it;
LEGACY_CLASSROOM_COOKIE_SECRETis not used;- the import writes nothing on GitHub, so I57's check moved to C8;
- M8-04 is dropped, so C4 is too.