Grading protocol 1
How a player that holds only the view gets verdicts from a backend. Any language can implement it; @astrolabe/server
is the reference implementation for Fetch-API runtimes (Node 22+, Deno, Bun, Cloudflare Workers, Hono).
Endpoints
Section titled “Endpoints”{endpoint} identifies one attempt, for example https://api.example.com/attempts/123. How the request is
authorised (cookie, bearer token, signed URL) is the host's business, and so is identifying the attempt: the host maps
{endpoint} to the attempt it authorised and hands that attempt to the grader. A grader never derives the attempt
from anything else in the request (extra path segments, the query, the body); from the path it reads only the final
segment, the action.
| Method and path | Request body | Success |
|---|---|---|
POST {endpoint}/check |
CheckRequest |
200 CheckResponse |
POST {endpoint}/submit |
SubmitRequest |
200 SubmitResponse |
- Request and response bodies are JSON (
Content-Type: application/json), UTF-8, at most 256 KiB. checkexists only under a practice policy. Under a test policy it answers403check_not_allowed, so a test never leaks verdicts before submission.submitis final: a second submit answers409attempt_closed.- Responses never contain answer-key data. Solutions are sent only by
submitand only when the policy reveals answers.
Bodies
Section titled “Bodies”TypeScript definitions live in @astrolabe/core (checker.ts, result.ts, answers.ts). JSON:
// CheckRequest{ "answers": { "kd3f...": "Gray", "ka0b...": "true" }, "page": "k7q2..." } // page is optional
// CheckResponse{ "result": { "score": 1, "max": 2, "correct": false, "slots": { "kd3f...": "correct" }, "groups": [ { "id": "kd3f...", "verdict": "correct", "score": 1, "max": 1 }, { "id": "kx91...", "verdict": "incorrect", "score": 0, "max": 1 } ], "pages": { "k7q2...": { "score": 1, "max": 2 } }, "counts": { "correct": 1, "partial": 0, "incorrect": 1, "empty": 0, "total": 2 } }, "checksLeft": null}
// SubmitRequest{ "answers": { "kd3f...": "Gray" } }
// SubmitResponse{ "result": { "...": "as above" }, "solution": { "kd3f...": "grey" }, "passed": true, "submittedAt": "2026-10-29T08:00:00.000Z" }Answers are strings only:
| Slot kind | Answer |
|---|---|
text, number |
what the student typed |
choice |
opaque id of the picked option |
flag |
"true" when selected; absent otherwise |
letter |
one letter (a crossword square); compared in capitals of the content language |
open |
what the student wrote, for the teacher; never graded |
A missing key, "" or a value longer than its slot kind allows counts as unanswered: 500 characters, 2000 for an
open slot, counted in Unicode code points. Unknown slot ids are ignored. A server stores a submission's answers
within those limits only (open answers included: Metadata.open lists their ids).
Errors
Section titled “Errors”Errors are application/problem+json (RFC 9457) with a machine-readable code:
| Status | code |
When |
|---|---|---|
| 400 | invalid_request |
body is not JSON or does not match the schema |
| 401 | unauthorized |
the host cannot identify the attempt |
| 403 | check_not_allowed |
check under a non-practice policy |
| 403 | checks_exhausted |
practice maxChecks used up |
| 404 | not_found |
unknown attempt or page |
| 405 | method_not_allowed |
not POST |
| 409 | attempt_closed |
already submitted |
| 413 | payload_too_large |
body over 256 KiB |
| 500 | internal_error |
anything else |
{ "type": "urn:astrolabe:problem:check_not_allowed", "title": "Checking is not allowed in this attempt", "status": 403, "code": "check_not_allowed" }Grading rules
Section titled “Grading rules”The server grades with engine.grade(key, answers, { page }) from @astrolabe/engine (or a port that passes the
conformance fixtures). Rules: see Scoring. Deadlines are enforced by the server; the
player only shows a countdown.