Skip to content

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).

{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.
  • check exists only under a practice policy. Under a test policy it answers 403 check_not_allowed, so a test never leaks verdicts before submission.
  • submit is final: a second submit answers 409 attempt_closed.
  • Responses never contain answer-key data. Solutions are sent only by submit and only when the policy reveals answers.

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 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" }

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.