{
  "openapi": "3.1.1",
  "info": {
    "title": "Gradally Answer Grading API",
    "version": "1.0.4-20260917",
    "description": "Implemented local contract for sandbox integration. No public server is provisioned by this kit. Institution-pinned server-to-server bearer access; never put secrets in a student browser. All results publishable=false."
  },
  "servers": [
    {
      "url": "https://gradally-api.example.invalid",
      "description": "PLACEHOLDER: obtain assigned institution sandbox origin separately."
    }
  ],
  "security": [
    {
      "streamBearer": []
    }
  ],
  "paths": {
    "/api/v1/streams/{stream_id}/": {
      "get": {
        "operationId": "streamManifest",
        "summary": "Read approved question mapping",
        "parameters": [
          {
            "name": "stream_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "400": {
            "description": "Invalid request/cursor or HTTPS required.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing/invalid/revoked bearer or inactive owner.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Stream/receipt not found in this scope.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "405": {
            "description": "Unsupported HTTP method.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Request allowance exhausted; this request was not processed. Wait before retrying.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying.",
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "503": {
            "description": "Capture disabled or temporarily unavailable. Retry-After alone does not enable capture.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying.",
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "200": {
            "description": "Current representation, or identical replay.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Manifest"
                }
              }
            }
          },
          "default": {
            "description": "Unexpected/proxy error; body may not be JSON. Never turn it into a grade."
          }
        },
        "description": "Manifest, result reads, change polling and acknowledgements share a separate delivery allowance within the institution: fixed 60s windows, IP1200 requests and authenticated stream600. Replays count. Delivery requests do not consume answer-intake allowance. On 429 wait at least Retry-After seconds, then retry the same request; no delivery SLA is implied."
      }
    },
    "/api/v1/streams/{stream_id}/answers/": {
      "post": {
        "operationId": "submitAnswer",
        "summary": "Store one version of one typed answer",
        "parameters": [
          {
            "name": "stream_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "Idempotency-Key",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string",
              "pattern": "^[A-Za-z0-9._~-]{1,160}$",
              "maxLength": 160
            },
            "description": "Optional outbox row key. A replay with the same key and the same body returns the same receipt; the same key with a different body is a 409 conflict. The body itself already makes exact replays safe."
          }
        ],
        "responses": {
          "400": {
            "description": "Invalid request/cursor or HTTPS required.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing/invalid/revoked bearer or inactive owner.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Stream/receipt not found in this scope.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "405": {
            "description": "Unsupported HTTP method.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Request allowance exhausted; this request was not processed. Wait before retrying.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying.",
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "503": {
            "description": "Capture disabled or temporarily unavailable. Retry-After alone does not enable capture.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying.",
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "409": {
            "description": "Version/key/content/acknowledgement conflict; refresh before retrying.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "413": {
            "description": "Body exceeds 98304 bytes.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "415": {
            "description": "Content-Type must be application/json.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "200": {
            "description": "Current representation, or identical replay.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Receipt"
                }
              }
            }
          },
          "202": {
            "description": "Stored receipt; grading has NOT necessarily completed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Receipt"
                }
              }
            },
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying.",
                "schema": {
                  "type": "string"
                }
              },
              "Location": {
                "description": "Relative current-result URL.",
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "default": {
            "description": "Unexpected/proxy error; body may not be JSON. Never turn it into a grade."
          }
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/Submission"
              }
            }
          }
        },
        "description": "Exactly one answer per request, not an array. JSON body <=98304 bytes. All fields required, unknown/duplicate fields rejected. Versions are integer literals, sequential from 1. Replay the same payload, including fingerprint and whitespace, after timeout/429. Per institution: fixed 60s windows, IP1200 requests and authenticated stream600; replays count. Bulk intake is not implemented; processing is asynchronous."
      }
    },
    "/api/v1/streams/{stream_id}/results/{receipt_id}/": {
      "get": {
        "operationId": "getResult",
        "summary": "Read current result",
        "parameters": [
          {
            "name": "stream_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "receipt_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "400": {
            "description": "Invalid request/cursor or HTTPS required.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing/invalid/revoked bearer or inactive owner.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Stream/receipt not found in this scope.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "405": {
            "description": "Unsupported HTTP method.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Request allowance exhausted; this request was not processed. Wait before retrying.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying.",
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "503": {
            "description": "Capture disabled or temporarily unavailable. Retry-After alone does not enable capture.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying.",
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "200": {
            "description": "Current representation, or identical replay.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Result"
                }
              }
            },
            "headers": {
              "ETag": {
                "schema": {
                  "type": "string"
                },
                "description": "Quoted digest; no conditional 304 contract is implemented."
              }
            }
          },
          "default": {
            "description": "Unexpected/proxy error; body may not be JSON. Never turn it into a grade."
          }
        },
        "description": "Manifest, result reads, change polling and acknowledgements share a separate delivery allowance within the institution: fixed 60s windows, IP1200 requests and authenticated stream600. Replays count. Delivery requests do not consume answer-intake allowance. On 429 wait at least Retry-After seconds, then retry the same request; no delivery SLA is implied."
      }
    },
    "/api/v1/streams/{stream_id}/submissions/": {
      "get": {
        "operationId": "lookupSubmission",
        "summary": "Recover the receipt of a submission ID (latest version, or one exact version)",
        "parameters": [
          {
            "name": "stream_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "submission_id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 160,
              "description": "Opaque ID; no leading/trailing whitespace or C0 controls (U+0000–U+001F); never a student name/contact.",
              "pattern": "^[^\\s\\u0000-\\u001f](?:[^\\u0000-\\u001f]*[^\\s\\u0000-\\u001f])?$(?![\\s\\S])"
            },
            "description": "The platform submission ID sent in the answer request."
          },
          {
            "name": "submission_version",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 2147483647
            },
            "description": "One exact version. Omit to read the latest received version."
          }
        ],
        "responses": {
          "400": {
            "description": "Invalid request/cursor or HTTPS required.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing/invalid/revoked bearer or inactive owner.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Stream/receipt not found in this scope.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "405": {
            "description": "Unsupported HTTP method.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Request allowance exhausted; this request was not processed. Wait before retrying.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying.",
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "503": {
            "description": "Capture disabled or temporarily unavailable. Retry-After alone does not enable capture.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying.",
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "200": {
            "description": "Current representation, or identical replay.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Result"
                }
              }
            },
            "headers": {
              "ETag": {
                "schema": {
                  "type": "string"
                },
                "description": "Quoted digest; no conditional 304 contract is implemented."
              }
            }
          },
          "default": {
            "description": "Unexpected/proxy error; body may not be JSON. Never turn it into a grade."
          }
        },
        "description": "Manifest, result reads, change polling and acknowledgements share a separate delivery allowance within the institution: fixed 60s windows, IP1200 requests and authenticated stream600. Replays count. Delivery requests do not consume answer-intake allowance. On 429 wait at least Retry-After seconds, then retry the same request; no delivery SLA is implied."
      }
    },
    "/api/v1/streams/{stream_id}/results/": {
      "get": {
        "operationId": "listReceipts",
        "summary": "Initial receipt scan; NOT a result-change cursor",
        "parameters": [
          {
            "name": "stream_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "schema": {
              "type": "string",
              "maxLength": 2048
            },
            "description": "Opaque cursor for this feed and stream only. Omit to start."
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 50
            }
          }
        ],
        "responses": {
          "400": {
            "description": "Invalid request/cursor or HTTPS required.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing/invalid/revoked bearer or inactive owner.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Stream/receipt not found in this scope.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "405": {
            "description": "Unsupported HTTP method.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Request allowance exhausted; this request was not processed. Wait before retrying.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying.",
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "503": {
            "description": "Capture disabled or temporarily unavailable. Retry-After alone does not enable capture.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying.",
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "200": {
            "description": "Current representation, or identical replay.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ResultPage"
                }
              }
            }
          },
          "default": {
            "description": "Unexpected/proxy error; body may not be JSON. Never turn it into a grade."
          }
        },
        "description": "Manifest, result reads, change polling and acknowledgements share a separate delivery allowance within the institution: fixed 60s windows, IP1200 requests and authenticated stream600. Replays count. Delivery requests do not consume answer-intake allowance. On 429 wait at least Retry-After seconds, then retry the same request; no delivery SLA is implied."
      }
    },
    "/api/v1/streams/{stream_id}/changes/": {
      "get": {
        "operationId": "listChanges",
        "summary": "Poll committed invalidations, then fetch current results",
        "parameters": [
          {
            "name": "stream_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "schema": {
              "type": "string",
              "maxLength": 2048
            },
            "description": "Opaque cursor for this feed and stream only. Omit to start."
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 50
            }
          }
        ],
        "responses": {
          "400": {
            "description": "Invalid request/cursor or HTTPS required.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing/invalid/revoked bearer or inactive owner.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Stream/receipt not found in this scope.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "405": {
            "description": "Unsupported HTTP method.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Request allowance exhausted; this request was not processed. Wait before retrying.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying.",
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "503": {
            "description": "Capture disabled or temporarily unavailable. Retry-After alone does not enable capture.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying.",
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "200": {
            "description": "Current representation, or identical replay.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ChangePage"
                }
              }
            }
          },
          "default": {
            "description": "Unexpected/proxy error; body may not be JSON. Never turn it into a grade."
          }
        },
        "description": "Manifest, result reads, change polling and acknowledgements share a separate delivery allowance within the institution: fixed 60s windows, IP1200 requests and authenticated stream600. Replays count. Delivery requests do not consume answer-intake allowance. On 429 wait at least Retry-After seconds, then retry the same request; no delivery SLA is implied."
      }
    },
    "/api/v1/streams/{stream_id}/acknowledgements/": {
      "post": {
        "operationId": "acknowledgeResult",
        "summary": "Acknowledge a durably stored shadow result",
        "parameters": [
          {
            "name": "stream_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "400": {
            "description": "Invalid request/cursor or HTTPS required.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing/invalid/revoked bearer or inactive owner.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Stream/receipt not found in this scope.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "405": {
            "description": "Unsupported HTTP method.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Request allowance exhausted; this request was not processed. Wait before retrying.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying.",
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "503": {
            "description": "Capture disabled or temporarily unavailable. Retry-After alone does not enable capture.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying.",
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "409": {
            "description": "Version/key/content/acknowledgement conflict; refresh before retrying.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "413": {
            "description": "Body exceeds 98304 bytes.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "415": {
            "description": "Content-Type must be application/json.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "200": {
            "description": "Current representation, or identical replay.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Acknowledged"
                }
              }
            }
          },
          "201": {
            "description": "First acknowledgement; not publication.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Acknowledged"
                }
              }
            }
          },
          "default": {
            "description": "Unexpected/proxy error; body may not be JSON. Never turn it into a grade."
          }
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/Acknowledgement"
              }
            }
          }
        },
        "description": "Manifest, result reads, change polling and acknowledgements share a separate delivery allowance within the institution: fixed 60s windows, IP1200 requests and authenticated stream600. Replays count. Delivery requests do not consume answer-intake allowance. On 429 wait at least Retry-After seconds, then retry the same request; no delivery SLA is implied."
      }
    }
  },
  "webhooks": {
    "resultChanged": {
      "post": {
        "summary": "Signed result notification (optional, configured by the Gradally operator)",
        "description": "Sent by Gradally to the HTTPS receiver the platform names, to its pinned public IPv4 address, when a receipt's current result changed. Verify the signature over the untouched body, store the event durably keyed by (stream_id, event_id), then answer 2xx; fetch the current result on any doubt.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookEvent"
              }
            }
          }
        },
        "responses": {
          "2XX": {
            "description": "Durably stored (or already stored); the sender stops retrying this event."
          },
          "503": {
            "description": "Temporarily unable to store; the sender retries with backoff (also on 408, 425, 429, 5xx)."
          },
          "default": {
            "description": "Other responses stop that stream's delivery until the Gradally operator retries; use the change feed meanwhile."
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "streamBearer": {
        "type": "http",
        "scheme": "bearer",
        "description": "Gradally service credential scoped to one stream/exam; provisioned separately."
      }
    },
    "schemas": {
      "Submission": {
        "type": "object",
        "properties": {
          "schema_version": {
            "const": "sanad-submission-1"
          },
          "submission_id": {
            "type": "string",
            "minLength": 1,
            "maxLength": 160,
            "description": "Opaque ID; no leading/trailing whitespace or C0 controls (U+0000–U+001F); never a student name/contact.",
            "pattern": "^[^\\s\\u0000-\\u001f](?:[^\\u0000-\\u001f]*[^\\s\\u0000-\\u001f])?$(?![\\s\\S])"
          },
          "submission_version": {
            "type": "integer",
            "minimum": 1,
            "maximum": 2147483647
          },
          "question_id": {
            "type": "string",
            "minLength": 1,
            "maxLength": 160,
            "description": "Opaque ID; no leading/trailing whitespace or C0 controls (U+0000–U+001F); never a student name/contact.",
            "pattern": "^[^\\s\\u0000-\\u001f](?:[^\\u0000-\\u001f]*[^\\s\\u0000-\\u001f])?$(?![\\s\\S])"
          },
          "question_fingerprint": {
            "type": "string",
            "pattern": "^[0-9a-f]{64}$",
            "minLength": 64,
            "maxLength": 64
          },
          "text": {
            "type": "string",
            "maxLength": 20000,
            "pattern": "^[^\\u0000\\ud800-\\udfff]*$",
            "description": "UTF-8 typed answer; empty string allowed. Preserve original text on retries."
          },
          "has_attachment": {
            "const": false
          }
        },
        "required": [
          "schema_version",
          "submission_id",
          "submission_version",
          "question_id",
          "question_fingerprint",
          "text",
          "has_attachment"
        ],
        "additionalProperties": false
      },
      "Receipt": {
        "type": "object",
        "properties": {
          "schema_version": {
            "const": "sanad-receipt-1"
          },
          "receipt_id": {
            "type": "string",
            "format": "uuid"
          },
          "submission_id": {
            "type": "string",
            "minLength": 1,
            "maxLength": 160,
            "description": "Opaque ID; no leading/trailing whitespace or C0 controls (U+0000–U+001F); never a student name/contact.",
            "pattern": "^[^\\s\\u0000-\\u001f](?:[^\\u0000-\\u001f]*[^\\s\\u0000-\\u001f])?$(?![\\s\\S])"
          },
          "submission_version": {
            "type": "integer",
            "minimum": 1
          },
          "created": {
            "type": "boolean"
          },
          "result_url": {
            "type": "string",
            "description": "Relative URL on this same institution origin."
          }
        },
        "required": [
          "schema_version",
          "receipt_id",
          "submission_id",
          "submission_version",
          "created",
          "result_url"
        ]
      },
      "Manifest": {
        "type": "object",
        "properties": {
          "schema_version": {
            "const": "sanad-partner-stream-1"
          },
          "stream_id": {
            "type": "string",
            "format": "uuid"
          },
          "exam_id": {
            "type": "string"
          },
          "questions": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "question_id": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 160,
                  "description": "Opaque ID; no leading/trailing whitespace or C0 controls (U+0000–U+001F); never a student name/contact.",
                  "pattern": "^[^\\s\\u0000-\\u001f](?:[^\\u0000-\\u001f]*[^\\s\\u0000-\\u001f])?$(?![\\s\\S])"
                },
                "question_fingerprint": {
                  "type": "string",
                  "pattern": "^[0-9a-f]{64}$",
                  "minLength": 64,
                  "maxLength": 64
                },
                "context_approved": {
                  "type": "boolean"
                },
                "max_mark": {
                  "type": "string",
                  "description": "Nonnegative decimal serialized as text; do not use binary floats for marks."
                }
              },
              "required": [
                "question_id",
                "question_fingerprint",
                "context_approved",
                "max_mark"
              ]
            }
          }
        },
        "required": [
          "schema_version",
          "stream_id",
          "exam_id",
          "questions"
        ]
      },
      "Score": {
        "type": "object",
        "properties": {
          "fraction": {
            "type": "string",
            "description": "Nonnegative decimal serialized as text; do not use binary floats for marks."
          },
          "earned": {
            "type": "string",
            "description": "Nonnegative decimal serialized as text; do not use binary floats for marks."
          },
          "maximum": {
            "type": "string",
            "description": "Nonnegative decimal serialized as text; do not use binary floats for marks."
          }
        },
        "required": [
          "fraction",
          "earned",
          "maximum"
        ]
      },
      "Criterion": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "label": {
            "type": "string"
          },
          "max_score": {
            "anyOf": [
              {
                "type": "string",
                "description": "Nonnegative decimal serialized as text; do not use binary floats for marks."
              },
              {
                "type": "null"
              }
            ]
          },
          "score": {
            "anyOf": [
              {
                "type": "string",
                "description": "Nonnegative decimal serialized as text; do not use binary floats for marks."
              },
              {
                "type": "null"
              }
            ]
          },
          "state": {
            "type": "string"
          },
          "blocked_by": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "quote": {
            "type": "string"
          },
          "state_label": {
            "type": "string"
          },
          "feedback": {
            "type": "string"
          }
        },
        "required": [
          "id",
          "label",
          "max_score",
          "score",
          "state",
          "blocked_by",
          "quote",
          "state_label",
          "feedback"
        ]
      },
      "Result": {
        "type": "object",
        "properties": {
          "schema_version": {
            "const": "sanad-partner-result-1"
          },
          "receipt_id": {
            "type": "string",
            "format": "uuid"
          },
          "submission_id": {
            "type": "string",
            "minLength": 1,
            "maxLength": 160,
            "description": "Opaque ID; no leading/trailing whitespace or C0 controls (U+0000–U+001F); never a student name/contact.",
            "pattern": "^[^\\s\\u0000-\\u001f](?:[^\\u0000-\\u001f]*[^\\s\\u0000-\\u001f])?$(?![\\s\\S])"
          },
          "submission_version": {
            "type": "integer",
            "minimum": 1
          },
          "question_id": {
            "type": "string",
            "minLength": 1,
            "maxLength": 160,
            "description": "Opaque ID; no leading/trailing whitespace or C0 controls (U+0000–U+001F); never a student name/contact.",
            "pattern": "^[^\\s\\u0000-\\u001f](?:[^\\u0000-\\u001f]*[^\\s\\u0000-\\u001f])?$(?![\\s\\S])"
          },
          "question_fingerprint": {
            "type": "string",
            "pattern": "^[0-9a-f]{64}$",
            "minLength": 64,
            "maxLength": 64
          },
          "grading_fingerprint": {
            "type": "string",
            "pattern": "^[0-9a-f]{64}$",
            "minLength": 64,
            "maxLength": 64
          },
          "decision_revision": {
            "type": "integer",
            "minimum": 0
          },
          "state": {
            "type": "string",
            "enum": [
              "processing",
              "ready",
              "unavailable",
              "action_required",
              "stale",
              "superseded"
            ]
          },
          "decision_applied": {
            "type": "boolean"
          },
          "publishable": {
            "const": false
          },
          "score": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Score"
              },
              {
                "type": "null"
              }
            ]
          },
          "criteria": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Criterion"
            }
          },
          "feedback": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "message": {
            "type": "string"
          },
          "result_digest": {
            "type": "string",
            "pattern": "^[0-9a-f]{64}$",
            "minLength": 64,
            "maxLength": 64
          },
          "delivery_token": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ]
          }
        },
        "required": [
          "schema_version",
          "receipt_id",
          "submission_id",
          "submission_version",
          "question_id",
          "question_fingerprint",
          "grading_fingerprint",
          "decision_revision",
          "state",
          "decision_applied",
          "publishable",
          "score",
          "criteria",
          "feedback",
          "message",
          "result_digest",
          "delivery_token"
        ],
        "description": "ready + decision_applied + score + delivery_token is an applied result. Always unpublished in this release. Non-ready results invalidate an earlier usable result; null is never zero. result_digest is an opaque version identifier; clients echo it without reconstructing it."
      },
      "ResultPage": {
        "type": "object",
        "properties": {
          "schema_version": {
            "const": "sanad-result-page-1"
          },
          "results": {
            "type": "array",
            "maxItems": 100,
            "items": {
              "$ref": "#/components/schemas/Result"
            }
          },
          "next_cursor": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ]
          },
          "has_more": {
            "type": "boolean"
          },
          "cursor_semantics": {
            "type": "string"
          }
        },
        "required": [
          "schema_version",
          "results",
          "next_cursor",
          "has_more",
          "cursor_semantics"
        ]
      },
      "ChangePage": {
        "type": "object",
        "properties": {
          "schema_version": {
            "const": "sanad-result-changes-1"
          },
          "changes": {
            "type": "array",
            "maxItems": 100,
            "items": {
              "type": "object",
              "properties": {
                "sequence": {
                  "type": "integer",
                  "minimum": 1
                },
                "receipt_id": {
                  "type": "string",
                  "format": "uuid"
                },
                "result_url": {
                  "type": "string"
                }
              },
              "required": [
                "sequence",
                "receipt_id",
                "result_url"
              ]
            }
          },
          "next_cursor": {
            "type": "string"
          },
          "has_more": {
            "type": "boolean"
          },
          "publishable": {
            "const": false
          },
          "semantics": {
            "type": "string"
          }
        },
        "required": [
          "schema_version",
          "changes",
          "next_cursor",
          "has_more",
          "publishable",
          "semantics"
        ]
      },
      "Acknowledgement": {
        "type": "object",
        "properties": {
          "schema_version": {
            "const": "sanad-result-ack-1"
          },
          "receipt_id": {
            "type": "string",
            "format": "uuid"
          },
          "submission_version": {
            "type": "integer",
            "minimum": 1
          },
          "decision_revision": {
            "type": "integer",
            "minimum": 1
          },
          "result_digest": {
            "type": "string",
            "pattern": "^[0-9a-f]{64}$",
            "minLength": 64,
            "maxLength": 64
          },
          "delivery_token": {
            "type": "string",
            "maxLength": 2048
          },
          "destination_record_id": {
            "type": "string",
            "minLength": 1,
            "maxLength": 160,
            "description": "Opaque ID; no leading/trailing whitespace or C0 controls (U+0000–U+001F); never a student name/contact.",
            "pattern": "^[^\\s\\u0000-\\u001f](?:[^\\u0000-\\u001f]*[^\\s\\u0000-\\u001f])?$(?![\\s\\S])"
          }
        },
        "required": [
          "schema_version",
          "receipt_id",
          "submission_version",
          "decision_revision",
          "result_digest",
          "delivery_token",
          "destination_record_id"
        ],
        "additionalProperties": false
      },
      "Acknowledged": {
        "type": "object",
        "properties": {
          "schema_version": {
            "const": "sanad-result-acknowledged-1"
          },
          "ack_id": {
            "type": "integer",
            "minimum": 1
          },
          "created": {
            "type": "boolean"
          },
          "result_digest": {
            "type": "string",
            "pattern": "^[0-9a-f]{64}$",
            "minLength": 64,
            "maxLength": 64
          },
          "publishable": {
            "const": false
          }
        },
        "required": [
          "schema_version",
          "ack_id",
          "created",
          "result_digest",
          "publishable"
        ]
      },
      "Error": {
        "type": "object",
        "properties": {
          "error": {
            "type": "string"
          },
          "message": {
            "type": "string"
          }
        },
        "required": [
          "error",
          "message"
        ]
      },
      "WebhookEvent": {
        "type": "object",
        "properties": {
          "schema_version": {
            "const": "sanad-result-webhook-1"
          },
          "event_id": {
            "type": "string",
            "format": "uuid"
          },
          "stream_id": {
            "type": "string",
            "format": "uuid"
          },
          "sequence": {
            "type": "integer",
            "minimum": 1
          },
          "receipt_id": {
            "type": "string",
            "format": "uuid"
          },
          "observed_at": {
            "type": "string",
            "description": "ISO 8601 time the sender observed this result."
          },
          "result_url": {
            "type": "string"
          },
          "publishable": {
            "const": false
          },
          "result": {
            "$ref": "#/components/schemas/Result"
          }
        },
        "required": [
          "schema_version",
          "event_id",
          "stream_id",
          "sequence",
          "receipt_id",
          "observed_at",
          "result_url",
          "publishable",
          "result"
        ],
        "description": "One signed POST per changed result, at least once. Headers X-Gradally-Webhook-Id (= event_id), X-Gradally-Webhook-Timestamp (Unix seconds) and X-Gradally-Webhook-Signature (sha256= HMAC-SHA256 of ASCII(timestamp) + \".\" + raw body, keyed by the separately delivered signing secret). Retries keep event_id and sequence but refresh observed_at and result. Any 2xx after durable storage confirms transport only; it is not an acknowledgement and not publication."
      }
    }
  },
  "x-publication-enabled": false,
  "x-outgoing-webhooks-implemented": true
}
