{
  "openapi": "3.1.1",
  "jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
  "info": {
    "title": "Scolavo Curriculum API",
    "version": "1.0.0",
    "summary": "Authenticated REST/JSON access to licensed Scolavo curriculum.",
    "description": "Plain REST over the same corpus, entitlements, metering and licence as the Scolavo MCP server —\nbuilt for code that already knows what it wants. Use this for ETL, sync jobs, LMS imports and\nanything that runs on a schedule; use MCP when a model is exploring.\n\n**Start here.** `GET /v1/license` reports which tiers and subjects your organization is licensed for\nand how much quota is left — it is unmetered and answers even when everything else is returning 403.\n`GET /v1/subjects` enumerates those subjects. Everything else keys off a `subjectSlug` or a\n`{tier}/{subjectSlug}/{classNum}` lesson id taken from those two. Slugs are matched EXACTLY; this\nAPI never guesses from a display name or a partial slug.\n\n**Costs are published, not discovered.** Every operation carries an `x-scolavo-cost` extension:\ntypical response bytes, measured against the shipped corpus, and which meters the call moves.\nMetering is per organization — a monthly `calls` counter on every request, plus `downloads`,\n`exports` and `testprepitems` where they apply, plus hourly velocity caps on calls, lesson reads\nand URL mints. All of them are visible at `GET /v1/license`.\n\n**Spend fewer bytes.** Four levers, in order of leverage. Send `Accept-Encoding: gzip` (or `br`) and\nthe JSON arrives compressed — measured across every route, that is 75.9% of the bytes for free, and\n85-96% on the list routes. Most HTTP clients send it and decode it without being asked; check yours,\nbecause it is the one lever that costs you nothing to pull. `detail=brief` takes a lesson from ~26 KB\nto ~2 KB. `include=` adds exactly the one optional section you need instead of all of them. And\n`If-None-Match`: content is immutable for a given `contentVersion`, so re-syncing unchanged material\nis a wall of 304s. A 304 still costs one `calls` unit — it re-ran the full licence gate — but no\nlesson read and no enumeration-alarm tick, which is what makes re-crawling a subject you already\nhold nearly free. The export routes are the exception: their body is a signed URL with a clock on\nit, so they carry no ETag at all. Poll `contentVersion` on the unmetered `GET /v1/license` to decide\nwhen a re-export is worth paying for.\n\n**Batch what MCP made you loop.** `POST /v1/media/urls` mints up to 100 signed URLs in one request.\nThe per-asset MCP equivalent was 34% of one partner's traffic for none of the information.\n\n**Three things that surprise people.** Every list response puts its rows in `items` — the same\nenvelope on every route, so one pagination helper works everywhere. Errors are nested:\n`{\"error\": {\"code\": …, \"message\": …}}`; branch on `code`, never on `message`. And the batch mint is\nall-or-nothing: one bad id fails the call rather than returning a partial result.\n\n**Reading lessons in bulk is the wrong door.** The lesson routes feed an enumeration alarm: an\norganization that reads more than 40% of its granted lessons in a single day is auto-suspended as a\nsuspected corpus clone. `/v1/exports/*` is the supported bulk path — cheaper, complete, untruncated,\nand not alarm-fed.\n\n**Licensing.** All content is licensed under the Scolavo Enterprise Content License Agreement (ECLA).\nEvery content response carries a `_license` envelope identifying the licence and a forensic tag;\nexport bundles additionally carry per-organization canaries. Redistribution outside the licensed\norganization is not permitted.",
    "termsOfService": "https://www.scolavo.com/terms",
    "contact": {
      "name": "Scolavo Licensing",
      "email": "licensing@scolavo.com",
      "url": "https://www.scolavo.com"
    },
    "license": {
      "name": "Scolavo Enterprise Content License Agreement (ECLA)",
      "url": "https://www.scolavo.com/terms"
    },
    "x-scolavo-content-version": "2026-08-04+4fd77ddd"
  },
  "servers": [
    {
      "url": "https://mcp.scolavo.com",
      "description": "Production"
    }
  ],
  "security": [
    {
      "oauth2": [
        "scolavo-mcp/content.read"
      ]
    }
  ],
  "tags": [
    {
      "name": "Discovery",
      "description": "What exists and what your licence covers. Cheap, cacheable, and where every integration should start."
    },
    {
      "name": "Content",
      "description": "Lessons, transcripts, quizzes. Metered as lesson reads and watched by the enumeration alarm."
    },
    {
      "name": "Media",
      "description": "Asset inventories and batch-minted signed URLs."
    },
    {
      "name": "Export",
      "description": "Whole-subject and whole-class bundles. The supported bulk path — not alarm-fed, and the only surface licensed to carry raw asset URLs."
    },
    {
      "name": "Test prep",
      "description": "Original Scolavo-authored practice items. Not actual exam questions."
    },
    {
      "name": "Progress",
      "description": "Learner progress, shared with scolavo.com. Requires an interactive learner token."
    },
    {
      "name": "Meta",
      "description": "This document."
    }
  ],
  "paths": {
    "/v1/openapi.json": {
      "get": {
        "tags": [
          "Meta"
        ],
        "operationId": "getOpenapi",
        "summary": "This OpenAPI document",
        "description": "The machine-readable description of this API. The only route that requires no authentication — a caller debugging a 401 has to be able to read the auth contract, and a client generator has to run before anyone owns a credential. It describes the API surface only: never licensed content, and never an organization's grants.\n\nEnum values for tiers and test-prep tests/sections are derived from the corpus actually deployed, so this document tracks the server rather than describing an older one. It is identical for every caller, which makes it the one response here a SHARED cache may keep.",
        "security": [],
        "responses": {
          "200": {
            "description": "The OpenAPI 3.1 document.",
            "headers": {
              "ETag": {
                "$ref": "#/components/headers/ETag"
              },
              "Cache-Control": {
                "$ref": "#/components/headers/CacheControl"
              },
              "Vary": {
                "$ref": "#/components/headers/Vary"
              },
              "Content-Encoding": {
                "$ref": "#/components/headers/ContentEncoding"
              },
              "X-Scolavo-Content-Version": {
                "$ref": "#/components/headers/ContentVersion"
              },
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "description": "An OpenAPI 3.1 document."
                }
              }
            }
          },
          "304": {
            "$ref": "#/components/responses/NotModified"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-scolavo-cost": {
          "typicalBytes": "≈122 KB",
          "meters": [],
          "note": "Unauthenticated, unmetered, and not audited — there is no caller identity to attribute it to. Fetch it once and cache it; the ETag will tell you when it moved."
        }
      }
    },
    "/v1/tiers": {
      "get": {
        "tags": [
          "Discovery"
        ],
        "operationId": "listTiers",
        "summary": "Curriculum tiers your licence covers",
        "description": "The curriculum tiers with how many subjects each holds, counted over your content grants only — a tier absent here is a tier your licence does not cover, and a count here never includes a subject you cannot read. The cheapest call in the API, and the right first probe after GET /v1/license.",
        "security": [
          {
            "oauth2": [
              "scolavo-mcp/content.read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "Tier counts.",
            "headers": {
              "ETag": {
                "$ref": "#/components/headers/ETag"
              },
              "Cache-Control": {
                "$ref": "#/components/headers/CacheControl"
              },
              "Vary": {
                "$ref": "#/components/headers/Vary"
              },
              "Content-Encoding": {
                "$ref": "#/components/headers/ContentEncoding"
              },
              "X-Scolavo-Content-Version": {
                "$ref": "#/components/headers/ContentVersion"
              },
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TierPage"
                }
              }
            }
          },
          "304": {
            "$ref": "#/components/responses/NotModified"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-scolavo-cost": {
          "typicalBytes": "≈416 B",
          "meters": [
            "calls"
          ],
          "note": "One call. No lesson read, no download."
        }
      }
    },
    "/v1/subjects": {
      "get": {
        "tags": [
          "Discovery"
        ],
        "operationId": "listSubjects",
        "summary": "List licensed subjects",
        "description": "Every subject your organization is licensed for, ordered topics → college → high → middle → primary and then by name. This is the canonical source of `subjectSlug` values; nothing else in this API enumerates them. 125 subjects exist in the corpus, but you see only your grants — and `total` counts the same filtered set, so it is safe as a progress denominator.\n\n`detail` moves the price by 8×: brief ≈2.5 KB per 25 rows, standard ≈4.9 KB, full ≈19.9 KB. Full adds tags, prerequisites and related subjects — subject-graph metadata that repeats identically on every page of a walk. Pull it once per subject from GET /v1/subjects/{subjectSlug} instead.",
        "security": [
          {
            "oauth2": [
              "scolavo-mcp/content.read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/tier"
          },
          {
            "$ref": "#/components/parameters/detail"
          },
          {
            "$ref": "#/components/parameters/cursor"
          },
          {
            "$ref": "#/components/parameters/limit"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of subjects.",
            "headers": {
              "ETag": {
                "$ref": "#/components/headers/ETag"
              },
              "Cache-Control": {
                "$ref": "#/components/headers/CacheControl"
              },
              "Vary": {
                "$ref": "#/components/headers/Vary"
              },
              "Content-Encoding": {
                "$ref": "#/components/headers/ContentEncoding"
              },
              "X-Scolavo-Content-Version": {
                "$ref": "#/components/headers/ContentVersion"
              },
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SubjectPage"
                }
              }
            }
          },
          "304": {
            "$ref": "#/components/responses/NotModified"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-scolavo-cost": {
          "typicalBytes": "≈2.4 KB (brief) / ≈5.3 KB (standard) / ≈22 KB (full) per 25 rows",
          "meters": [
            "calls"
          ],
          "note": "Metadata only — not metered as a lesson read, and not seen by the enumeration alarm. A full 125-subject walk at limit=100 is 2 calls."
        }
      }
    },
    "/v1/subjects/{subjectSlug}": {
      "get": {
        "tags": [
          "Discovery"
        ],
        "operationId": "getSubject",
        "summary": "One subject and its place in the curriculum",
        "description": "One subject: class COUNT, media coverage, difficulty, tags, prerequisites and related subjects, plus `classesUrl` pointing at the class list.\n\nThe class LIST is deliberately not inlined — inlining ~30 class rows into every subject lookup is how the MCP list responses reached 47 KB. Follow `classesUrl` when you want them.\n\n`detail` is accepted here for consistency with the other routes, and validated exactly as strictly, but it cannot change this answer: the subject graph is returned at every level, so brief, standard and full are byte-identical (measured across all 125 subjects) and share ONE ETag. Widening or narrowing it costs nothing and gains nothing — a validator you already hold stays valid across all three.",
        "security": [
          {
            "oauth2": [
              "scolavo-mcp/content.read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/pathSubjectSlug"
          },
          {
            "$ref": "#/components/parameters/tier"
          },
          {
            "$ref": "#/components/parameters/detail"
          }
        ],
        "responses": {
          "200": {
            "description": "The subject.",
            "headers": {
              "ETag": {
                "$ref": "#/components/headers/ETag"
              },
              "Cache-Control": {
                "$ref": "#/components/headers/CacheControl"
              },
              "Vary": {
                "$ref": "#/components/headers/Vary"
              },
              "Content-Encoding": {
                "$ref": "#/components/headers/ContentEncoding"
              },
              "X-Scolavo-Content-Version": {
                "$ref": "#/components/headers/ContentVersion"
              },
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SubjectDetail"
                }
              }
            }
          },
          "304": {
            "$ref": "#/components/responses/NotModified"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-scolavo-cost": {
          "typicalBytes": "≈750 B",
          "meters": [
            "calls"
          ],
          "note": "Metadata only — not metered as a lesson read, and not seen by the enumeration alarm."
        }
      }
    },
    "/v1/subjects/{subjectSlug}/classes": {
      "get": {
        "tags": [
          "Discovery"
        ],
        "operationId": "listClasses",
        "summary": "A subject's classes",
        "description": "The ordered class list for one subject. Every entry carries a ready-made `lessonId` — never build one by hand — and the availability flags (video / narration / transcript) that tell you whether a later transcript or media call will return anything. Checking `hasTranscript` here is how you avoid paying a lesson read for an empty transcript.",
        "security": [
          {
            "oauth2": [
              "scolavo-mcp/content.read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/pathSubjectSlug"
          },
          {
            "$ref": "#/components/parameters/tier"
          },
          {
            "$ref": "#/components/parameters/cursor"
          },
          {
            "$ref": "#/components/parameters/limit"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of classes.",
            "headers": {
              "ETag": {
                "$ref": "#/components/headers/ETag"
              },
              "Cache-Control": {
                "$ref": "#/components/headers/CacheControl"
              },
              "Vary": {
                "$ref": "#/components/headers/Vary"
              },
              "Content-Encoding": {
                "$ref": "#/components/headers/ContentEncoding"
              },
              "X-Scolavo-Content-Version": {
                "$ref": "#/components/headers/ContentVersion"
              },
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClassPage"
                }
              }
            }
          },
          "304": {
            "$ref": "#/components/responses/NotModified"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-scolavo-cost": {
          "typicalBytes": "≈3.5 KB per 25 classes",
          "meters": [
            "calls"
          ],
          "note": "Metadata only — not metered as a lesson read."
        }
      }
    },
    "/v1/lessons/{tier}/{subjectSlug}/{classNum}": {
      "get": {
        "tags": [
          "Content"
        ],
        "operationId": "getLesson",
        "summary": "One class lesson",
        "description": "A single class as structured JSON: cards with their written body, availability flags, server-derived key terms and an estimated study time.\n\nThe levers, measured: `detail=brief` ≈2 KB (card ids, labels and flags — no prose at all, for crawling structure), `detail=standard` ≈7.5 KB (adds bodies, bullets, illustration alt text and key terms), `detail=full` ≈26 KB (adds every `include` section and lifts the text cap). `include=` adds one section at a time on top of any level, which is almost always cheaper than jumping to full.\n\nLong text is capped per level, and the response sets `truncated: true` when anything was cut — a pipeline that stores a half-record believing it holds the lesson is the failure that flag exists to prevent. `detail=full` and the export routes are the complete-text paths.\n\nIf you want the whole subject, use GET /v1/exports/subjects/{subjectSlug}: cheaper, complete, and not alarm-fed.",
        "security": [
          {
            "oauth2": [
              "scolavo-mcp/content.read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/pathTier"
          },
          {
            "$ref": "#/components/parameters/pathSubjectSlug"
          },
          {
            "$ref": "#/components/parameters/pathClassNum"
          },
          {
            "$ref": "#/components/parameters/detail"
          },
          {
            "$ref": "#/components/parameters/include"
          }
        ],
        "responses": {
          "200": {
            "description": "The lesson.",
            "headers": {
              "ETag": {
                "$ref": "#/components/headers/ETag"
              },
              "Cache-Control": {
                "$ref": "#/components/headers/CacheControl"
              },
              "Vary": {
                "$ref": "#/components/headers/Vary"
              },
              "Content-Encoding": {
                "$ref": "#/components/headers/ContentEncoding"
              },
              "X-Scolavo-Content-Version": {
                "$ref": "#/components/headers/ContentVersion"
              },
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Lesson"
                }
              }
            }
          },
          "304": {
            "$ref": "#/components/responses/NotModified"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-scolavo-cost": {
          "typicalBytes": "≈2.5 KB (brief) / ≈14 KB (standard) / ≈30 KB (full) — median over a 200-lesson sample drawn in proportion to the corpus, which is 41% College; a Primary lesson runs ≈7 KB and a College one ≈22 KB",
          "meters": [
            "calls",
            "lessonreads/hour",
            "enumeration/day"
          ],
          "note": "One lesson read. Counts against the hourly lessonReadsPerHour cap and feeds the 40%-of-grant enumeration alarm. A 304 costs one call but no lesson read and no alarm tick."
        }
      }
    },
    "/v1/lessons/{tier}/{subjectSlug}/{classNum}/transcript": {
      "get": {
        "tags": [
          "Content"
        ],
        "operationId": "getLessonTranscript",
        "summary": "The spoken transcript of one class",
        "description": "The complete spoken text of a class, both as one joined markdown string and as per-card rows in `items`.\n\nWhere a card has no separate narration script, its BODY is the narration — that is deliberate de-duplication in the corpus, not missing data. Cards with neither narration nor a video voiceover are omitted from `items` entirely.\n\nCheck `hasTranscript` on the class list first: a class without one returns an empty transcript and still costs a lesson read.",
        "security": [
          {
            "oauth2": [
              "scolavo-mcp/content.read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/pathTier"
          },
          {
            "$ref": "#/components/parameters/pathSubjectSlug"
          },
          {
            "$ref": "#/components/parameters/pathClassNum"
          }
        ],
        "responses": {
          "200": {
            "description": "The transcript.",
            "headers": {
              "ETag": {
                "$ref": "#/components/headers/ETag"
              },
              "Cache-Control": {
                "$ref": "#/components/headers/CacheControl"
              },
              "Vary": {
                "$ref": "#/components/headers/Vary"
              },
              "Content-Encoding": {
                "$ref": "#/components/headers/ContentEncoding"
              },
              "X-Scolavo-Content-Version": {
                "$ref": "#/components/headers/ContentVersion"
              },
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Transcript"
                }
              }
            }
          },
          "304": {
            "$ref": "#/components/responses/NotModified"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-scolavo-cost": {
          "typicalBytes": "≈15 KB joined text, ≈30 KB with the per-card rows",
          "meters": [
            "calls",
            "lessonreads/hour",
            "enumeration/day"
          ],
          "note": "One lesson read. Counts against the hourly lessonReadsPerHour cap and feeds the 40%-of-grant enumeration alarm. A 304 costs one call but no lesson read and no alarm tick."
        }
      }
    },
    "/v1/lessons/{tier}/{subjectSlug}/{classNum}/quiz": {
      "get": {
        "tags": [
          "Content"
        ],
        "operationId": "getLessonQuiz",
        "summary": "The quiz and inline checks for one class",
        "description": "The end-of-class quiz in `items` (question, options, zero-based answer index) plus the mid-lesson `inlineChecks`, which carry the graduated hints and the rationale explaining why the answer is right. The answer key IS included — that is the point of the route.\n\nEquivalent to the lesson route with include=quiz,inlineChecks, minus the card bodies.",
        "security": [
          {
            "oauth2": [
              "scolavo-mcp/content.read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/pathTier"
          },
          {
            "$ref": "#/components/parameters/pathSubjectSlug"
          },
          {
            "$ref": "#/components/parameters/pathClassNum"
          }
        ],
        "responses": {
          "200": {
            "description": "Quiz and inline checks.",
            "headers": {
              "ETag": {
                "$ref": "#/components/headers/ETag"
              },
              "Cache-Control": {
                "$ref": "#/components/headers/CacheControl"
              },
              "Vary": {
                "$ref": "#/components/headers/Vary"
              },
              "Content-Encoding": {
                "$ref": "#/components/headers/ContentEncoding"
              },
              "X-Scolavo-Content-Version": {
                "$ref": "#/components/headers/ContentVersion"
              },
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/QuizDoc"
                }
              }
            }
          },
          "304": {
            "$ref": "#/components/responses/NotModified"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-scolavo-cost": {
          "typicalBytes": "≈2.6 KB (≈1.6 KB for a Primary class)",
          "meters": [
            "calls",
            "lessonreads/hour",
            "enumeration/day"
          ],
          "note": "One lesson read. Counts against the hourly lessonReadsPerHour cap and feeds the 40%-of-grant enumeration alarm. A 304 costs one call but no lesson read and no alarm tick. The small response is not a small read: assessment content is the most directly reusable part of the corpus, so it is metered like any other lesson."
        }
      }
    },
    "/v1/lessons/{tier}/{subjectSlug}/{classNum}/media": {
      "get": {
        "tags": [
          "Media"
        ],
        "operationId": "listLessonMedia",
        "summary": "A class's media assets",
        "description": "The media inventory for one class: every asset as a typed `mediaId` with `url` always null. Listing never mints — that is what makes listing cheap. Mint up to 100 ids in ONE call at POST /v1/media/urls.\n\nA class carries a median of 31 assets (an audio file and an illustration per card, plus video where it exists), so filter with `type` before you build a batch.",
        "security": [
          {
            "oauth2": [
              "scolavo-mcp/content.read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/pathTier"
          },
          {
            "$ref": "#/components/parameters/pathSubjectSlug"
          },
          {
            "$ref": "#/components/parameters/pathClassNum"
          },
          {
            "$ref": "#/components/parameters/type"
          },
          {
            "$ref": "#/components/parameters/cursor"
          },
          {
            "$ref": "#/components/parameters/limit"
          }
        ],
        "responses": {
          "200": {
            "description": "The asset inventory.",
            "headers": {
              "ETag": {
                "$ref": "#/components/headers/ETag"
              },
              "Cache-Control": {
                "$ref": "#/components/headers/CacheControl"
              },
              "Vary": {
                "$ref": "#/components/headers/Vary"
              },
              "Content-Encoding": {
                "$ref": "#/components/headers/ContentEncoding"
              },
              "X-Scolavo-Content-Version": {
                "$ref": "#/components/headers/ContentVersion"
              },
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MediaList"
                }
              }
            }
          },
          "304": {
            "$ref": "#/components/responses/NotModified"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-scolavo-cost": {
          "typicalBytes": "≈2.9 KB (31 assets)",
          "meters": [
            "calls",
            "lessonreads/hour",
            "enumeration/day"
          ],
          "note": "One lesson read. Counts against the hourly lessonReadsPerHour cap and feeds the 40%-of-grant enumeration alarm. A 304 costs one call but no lesson read and no alarm tick. No download is metered — nothing is minted here."
        }
      }
    },
    "/v1/media/urls": {
      "post": {
        "tags": [
          "Media"
        ],
        "operationId": "mintMediaUrls",
        "summary": "Batch-mint signed media URLs (up to 100)",
        "description": "Mint short-lived signed URLs for up to 100 media assets in ONE request.\n\nThis route is why this API exists. A class has ~31 assets, so a subject-wide media sync through the per-asset MCP tool was ~930 round trips; here it is 10. Results come back in `items` in REQUEST order, so a client can zip them against its input positionally.\n\n**The batch is all-or-nothing.** One unknown mediaId fails the whole call with 404 UNKNOWN_MEDIA and nothing is minted; one ungranted subject anywhere in the batch fails it with 403 before a single unit is metered, so an unlicensed id can never ride along inside a licensed batch. Validate ids against the media route rather than probing with a large batch.\n\nURLs live for `expiresIn` seconds (default 900, max 900 — or 3600 with the longUrls override) and are deliberately short-lived: a URL that outlived a licence suspension would outlive the licence. Do not cache or store them; re-mint. This response is never cacheable and carries no ETag, because a 304 would hand back a URL that has already expired.",
        "security": [
          {
            "oauth2": [
              "scolavo-mcp/media.download"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/MintRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The minted URLs, in request order.",
            "headers": {
              "Cache-Control": {
                "$ref": "#/components/headers/CacheControl"
              },
              "Vary": {
                "$ref": "#/components/headers/Vary"
              },
              "Content-Encoding": {
                "$ref": "#/components/headers/ContentEncoding"
              },
              "X-Scolavo-Content-Version": {
                "$ref": "#/components/headers/ContentVersion"
              },
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MintResponse"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-scolavo-cost": {
          "typicalBytes": "≈1.3 KB per URL (≈133 KB for a full batch of 100) — dominated by the SigV4 signature and session token, so it compresses ~95% because that token repeats across the batch",
          "meters": [
            "calls",
            "downloads (one per id in the batch)",
            "mints/hour (one per id)"
          ],
          "note": "One monthly download per id in the batch, charged before minting. The batch consumes 100 units of the hourly urlMintsPerHour cap, not one — batching saves round trips and latency, never quota. Every minted URL is written to the durable export ledger."
        }
      }
    },
    "/v1/search": {
      "get": {
        "tags": [
          "Discovery"
        ],
        "operationId": "search",
        "summary": "Search titles, labels, bullets and quiz questions",
        "description": "Substring search across lesson titles, subtitles, card labels, key-point bullets and quiz questions. It does NOT search full body text — a phrase that appears only inside a card body will not be found. Export the subject and index it yourself if you need that.\n\nHits are filtered to your content grants before pagination, so `total` is the count within your licence. Each hit carries a ready-to-use lessonId and the field that matched, which is usually enough to decide whether it is worth a lesson read.",
        "security": [
          {
            "oauth2": [
              "scolavo-mcp/content.read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/q"
          },
          {
            "$ref": "#/components/parameters/tier"
          },
          {
            "$ref": "#/components/parameters/subject"
          },
          {
            "$ref": "#/components/parameters/cursor"
          },
          {
            "$ref": "#/components/parameters/limit"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of hits.",
            "headers": {
              "ETag": {
                "$ref": "#/components/headers/ETag"
              },
              "Cache-Control": {
                "$ref": "#/components/headers/CacheControl"
              },
              "Vary": {
                "$ref": "#/components/headers/Vary"
              },
              "Content-Encoding": {
                "$ref": "#/components/headers/ContentEncoding"
              },
              "X-Scolavo-Content-Version": {
                "$ref": "#/components/headers/ContentVersion"
              },
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SearchPage"
                }
              }
            }
          },
          "304": {
            "$ref": "#/components/responses/NotModified"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-scolavo-cost": {
          "typicalBytes": "≈5.4 KB per 25 hits",
          "meters": [
            "calls"
          ],
          "note": "Not a lesson read — snippets come from the prebuilt index, never from lesson objects."
        }
      }
    },
    "/v1/exports/subjects/{subjectSlug}": {
      "get": {
        "tags": [
          "Export"
        ],
        "operationId": "exportSubject",
        "summary": "Export a whole subject as a stamped bundle",
        "description": "An entire subject — every class — as one stamped zip: per-class lesson JSON, transcripts, a media manifest carrying the raw asset URLs, and LICENSE.txt.\n\n**This is the supported bulk path.** Walking the lesson routes to assemble the same subject costs a metered lesson read per class and feeds the enumeration alarm that auto-suspends suspected corpus cloning; this is not alarm-fed, returns complete untruncated text, and is the only surface licensed to hand you raw media URLs.\n\nBundles are stamped per organization: the manifest carries your licence id and forensic tag, and each class body carries whitespace-level canaries that survive stripping the `_license` envelope.\n\nCheck your remaining export allowance at GET /v1/license first — exports are typically the tightest quota on a plan. Conditional caching depends on `format`, and the difference is worth building for.\n\n`format=inline` carries an `ETag`: the body IS the bundle, so send the tag back as `If-None-Match` and an unchanged subject answers `304` with an empty body — one `calls` unit, and NO downloads and NO export. Measured over the whole corpus unchanged: a defensive re-sync of all 125 subjects costs 3,680 download units, 125 export units and 44,442,539 bytes without a validator, and 125 calls with no body bytes at all with one.\n\n`format=zip_url` (the default) carries NO ETag and is `no-store`: its body is a signed URL that expires in fifteen minutes, and a 304 would hand you a dead link you could not tell apart from a live one. Gate that one on `contentVersion` instead — poll GET /v1/license (unmetered) and re-export only when it moves.\n\nEither way, re-exporting unchanged content with no `If-None-Match` bills the full download-per-class cost again.",
        "security": [
          {
            "oauth2": [
              "scolavo-mcp/content.export"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/pathSubjectSlug"
          },
          {
            "$ref": "#/components/parameters/tier"
          },
          {
            "$ref": "#/components/parameters/format"
          }
        ],
        "responses": {
          "200": {
            "description": "A signed bundle URL (`format=zip_url`), or the bundle inline (`format=inline`, which also carries an `ETag`).",
            "headers": {
              "ETag": {
                "$ref": "#/components/headers/ETag"
              },
              "Cache-Control": {
                "$ref": "#/components/headers/CacheControl"
              },
              "Vary": {
                "$ref": "#/components/headers/Vary"
              },
              "Content-Encoding": {
                "$ref": "#/components/headers/ContentEncoding"
              },
              "X-Scolavo-Content-Version": {
                "$ref": "#/components/headers/ContentVersion"
              },
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ExportResult"
                }
              }
            }
          },
          "304": {
            "$ref": "#/components/responses/NotModified",
            "description": "Not modified — returned only for `format=inline`, which is the format whose body is the bundle itself. Costs one `calls` unit and nothing else: no download per class, no export unit, no body. `format=zip_url` never answers 304; it carries no ETag, because its body is a signed URL that expires."
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "413": {
            "$ref": "#/components/responses/PayloadTooLarge"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-scolavo-cost": {
          "typicalBytes": "≈400 B (format=zip_url); the zip itself is ≈1-8 MB depending on class count",
          "meters": [
            "calls",
            "exports (1)",
            "downloads (one PER CLASS)"
          ],
          "note": "A 30-class subject meters 30 downloads and 1 export. A 304 on format=inline meters the call only — no downloads, no export. format=inline puts the whole bundle in the response body and a large subject will exceed the gateway limit (413) — prefer zip_url unless your runner cannot follow a signed URL. Written to the durable export ledger; a 304 writes no ledger row, because nothing was served."
        }
      }
    },
    "/v1/exports/classes/{tier}/{subjectSlug}/{classNum}": {
      "get": {
        "tags": [
          "Export"
        ],
        "operationId": "exportClass",
        "summary": "Export one class as a stamped bundle",
        "description": "One class as a stamped bundle: the lesson JSON, its transcript where the class is narrated, a media manifest with raw asset URLs, and LICENSE.txt. Same stamping and canaries as a subject export.\n\nUse this when you need a single class complete with its media URLs. Do NOT loop it to cover a subject: each call burns one export, where a subject export burns one in total. Conditional caching depends on `format`, and the difference is worth building for.\n\n`format=inline` carries an `ETag`: the body IS the bundle, so send the tag back as `If-None-Match` and an unchanged subject answers `304` with an empty body — one `calls` unit, and NO downloads and NO export. Measured over the whole corpus unchanged: a defensive re-sync of all 125 subjects costs 3,680 download units, 125 export units and 44,442,539 bytes without a validator, and 125 calls with no body bytes at all with one.\n\n`format=zip_url` (the default) carries NO ETag and is `no-store`: its body is a signed URL that expires in fifteen minutes, and a 304 would hand you a dead link you could not tell apart from a live one. Gate that one on `contentVersion` instead — poll GET /v1/license (unmetered) and re-export only when it moves.\n\nEither way, re-exporting unchanged content with no `If-None-Match` bills the full download-per-class cost again.",
        "security": [
          {
            "oauth2": [
              "scolavo-mcp/content.export"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/pathTier"
          },
          {
            "$ref": "#/components/parameters/pathSubjectSlug"
          },
          {
            "$ref": "#/components/parameters/pathClassNum"
          },
          {
            "$ref": "#/components/parameters/format"
          }
        ],
        "responses": {
          "200": {
            "description": "A signed bundle URL (`format=zip_url`), or the bundle inline (`format=inline`, which also carries an `ETag`).",
            "headers": {
              "ETag": {
                "$ref": "#/components/headers/ETag"
              },
              "Cache-Control": {
                "$ref": "#/components/headers/CacheControl"
              },
              "Vary": {
                "$ref": "#/components/headers/Vary"
              },
              "Content-Encoding": {
                "$ref": "#/components/headers/ContentEncoding"
              },
              "X-Scolavo-Content-Version": {
                "$ref": "#/components/headers/ContentVersion"
              },
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ExportResult"
                }
              }
            }
          },
          "304": {
            "$ref": "#/components/responses/NotModified",
            "description": "Not modified — returned only for `format=inline`, which is the format whose body is the bundle itself. Costs one `calls` unit and nothing else: no download per class, no export unit, no body. `format=zip_url` never answers 304; it carries no ETag, because its body is a signed URL that expires."
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "413": {
            "$ref": "#/components/responses/PayloadTooLarge"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-scolavo-cost": {
          "typicalBytes": "≈400 B (format=zip_url); the zip itself is ≈40-80 KB",
          "meters": [
            "calls",
            "exports (1)",
            "downloads (1)"
          ],
          "note": "One export and one download, regardless of format. A 304 on format=inline meters the call only."
        }
      }
    },
    "/v1/testprep": {
      "get": {
        "tags": [
          "Test prep"
        ],
        "operationId": "listTestprep",
        "summary": "Test-prep catalog",
        "description": "The Scolavo practice tests with their sections and per-section item counts. All items are original Scolavo-authored practice material — not actual exam questions.\n\nThe item counts here let you size a walk before you start paying per item, and the section ids are the only valid values for the `section` filter on the items route.",
        "security": [
          {
            "oauth2": [
              "scolavo-mcp/content.read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "The catalog.",
            "headers": {
              "ETag": {
                "$ref": "#/components/headers/ETag"
              },
              "Cache-Control": {
                "$ref": "#/components/headers/CacheControl"
              },
              "Vary": {
                "$ref": "#/components/headers/Vary"
              },
              "Content-Encoding": {
                "$ref": "#/components/headers/ContentEncoding"
              },
              "X-Scolavo-Content-Version": {
                "$ref": "#/components/headers/ContentVersion"
              },
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TestprepCatalogPage"
                }
              }
            }
          },
          "304": {
            "$ref": "#/components/responses/NotModified"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-scolavo-cost": {
          "typicalBytes": "≈900 B",
          "meters": [
            "calls"
          ],
          "note": "Metadata only. Unlike the items route, this one is not metered per item."
        },
        "x-scolavo-sections": {
          "digital-sat": [
            "rw",
            "math"
          ],
          "act": [
            "english",
            "math",
            "reading",
            "science"
          ],
          "psat-nmsqt": [
            "rw",
            "math"
          ],
          "caaspp": [
            "ela",
            "math",
            "science"
          ]
        }
      }
    },
    "/v1/testprep/{test}/items": {
      "get": {
        "tags": [
          "Test prep"
        ],
        "operationId": "listTestprepItems",
        "summary": "A page of practice items",
        "description": "Practice items for one test, optionally one section. Items carry the prompt, options, zero-based answer index, domain, difficulty and a rationale; many carry per-option rationales, and reading items carry a passage.\n\n**This is the only route metered PER ITEM** rather than per call, because an item bank is the most directly resellable content in the corpus. Page what you need.\n\nTwo behaviours worth knowing before you write the loop. If your remaining monthly item allowance is smaller than `limit`, the page is SHORTENED to what is left rather than refused — compare what you received against `total`, not against `limit`. And the ETag on this route takes that shortened size into account, so revalidation works under an allowance too: an unchanged page answers 304 and re-bills NO items, while a page that would now come back shorter carries a new tag and is served — and billed — as normal.\n\n`section` must belong to THIS test: the parameter enum is the union across all tests, so \"rw\" passes validation on an ACT request and then answers 404 UNKNOWN_SECTION listing the sections the ACT does have. The per-test map is on this operation as `x-scolavo-sections`.",
        "security": [
          {
            "oauth2": [
              "scolavo-mcp/testprep.read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/pathTest"
          },
          {
            "$ref": "#/components/parameters/section"
          },
          {
            "$ref": "#/components/parameters/cursor"
          },
          {
            "$ref": "#/components/parameters/limit"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of items.",
            "headers": {
              "ETag": {
                "$ref": "#/components/headers/ETag"
              },
              "Cache-Control": {
                "$ref": "#/components/headers/CacheControl"
              },
              "Vary": {
                "$ref": "#/components/headers/Vary"
              },
              "Content-Encoding": {
                "$ref": "#/components/headers/ContentEncoding"
              },
              "X-Scolavo-Content-Version": {
                "$ref": "#/components/headers/ContentVersion"
              },
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TestprepItemPage"
                }
              }
            }
          },
          "304": {
            "$ref": "#/components/responses/NotModified"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-scolavo-cost": {
          "typicalBytes": "≈770 B per item (≈19 KB per 25)",
          "meters": [
            "calls",
            "testprepitems (one PER ITEM RETURNED)",
            "lessonreads/hour"
          ],
          "note": "Metered on the items actually served, never on the requested page size, and charged before they ship. A 304 meters the call only: no items and no lesson-read unit. Also counts against the hourly lesson-read cap. Not seen by the enumeration alarm, which is lesson-keyed."
        },
        "x-scolavo-sections": {
          "digital-sat": [
            "rw",
            "math"
          ],
          "act": [
            "english",
            "math",
            "reading",
            "science"
          ],
          "psat-nmsqt": [
            "rw",
            "math"
          ],
          "caaspp": [
            "ela",
            "math",
            "science"
          ]
        }
      }
    },
    "/v1/license": {
      "get": {
        "tags": [
          "Discovery"
        ],
        "operationId": "getLicense",
        "summary": "Licence, grants, quota and corpus version",
        "description": "Your organization's plan: tier, status, content grants, current usage against every monthly quota, the hourly rate limits, ECLA version and the corpus version being served.\n\n**Unmetered, and deliberately reachable when nothing else is.** A suspended, expired or pre-ECLA organization can still call this and read exactly why it is blocked, instead of receiving a 403 with nothing to act on. It also answers at zero remaining quota.\n\nEvery meter a call can be charged against appears in `quota` — a counter you are billed on but cannot see here would be a bug. Poll it before a bulk job and after any unexpected 429.\n\n`contentVersion` is the cheapest sync signal in the API: poll this one route, and only re-pull when it moves. Never cached, because the counters in it move on every other call.",
        "security": [
          {
            "oauth2": [
              "scolavo-mcp/content.read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "Licence status.",
            "headers": {
              "Cache-Control": {
                "$ref": "#/components/headers/CacheControl"
              },
              "Vary": {
                "$ref": "#/components/headers/Vary"
              },
              "Content-Encoding": {
                "$ref": "#/components/headers/ContentEncoding"
              },
              "X-Scolavo-Content-Version": {
                "$ref": "#/components/headers/ContentVersion"
              },
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/LicenseStatus"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-scolavo-cost": {
          "typicalBytes": "≈800 B",
          "meters": [],
          "note": "Unmetered — this call does not move the `calls` counter. No ETag: the whole point is that it is current."
        }
      }
    },
    "/v1/progress": {
      "get": {
        "tags": [
          "Progress"
        ],
        "operationId": "getProgress",
        "summary": "Learner progress",
        "description": "The signed-in learner's XP, streak and per-class completion — the SAME record scolavo.com reads and writes, so what you see here is what the learner sees on their dashboard.\n\nRequires an interactive learner token. A client_credentials token carries no learner identity and gets 403 LEARNER_REQUIRED; there is no token that reads another learner's progress.\n\nA class with no record is simply ABSENT from `items` — absence means \"not started\", not \"missing data\". This route needs only org membership in good standing, not a content grant for the subject: it is the learner's own data.",
        "security": [
          {
            "oauth2": [
              "scolavo-mcp/progress.read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/learnerId"
          },
          {
            "$ref": "#/components/parameters/subjectSlugQuery"
          },
          {
            "$ref": "#/components/parameters/cursor"
          },
          {
            "$ref": "#/components/parameters/limit"
          }
        ],
        "responses": {
          "200": {
            "description": "The learner's progress.",
            "headers": {
              "ETag": {
                "$ref": "#/components/headers/ETag"
              },
              "Cache-Control": {
                "$ref": "#/components/headers/CacheControl"
              },
              "Vary": {
                "$ref": "#/components/headers/Vary"
              },
              "Content-Encoding": {
                "$ref": "#/components/headers/ContentEncoding"
              },
              "X-Scolavo-Content-Version": {
                "$ref": "#/components/headers/ContentVersion"
              },
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProgressSummary"
                }
              }
            }
          },
          "304": {
            "$ref": "#/components/responses/NotModified"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-scolavo-cost": {
          "typicalBytes": "≈150 B + ≈120 B per recorded class",
          "meters": [
            "calls"
          ],
          "note": "No lesson read. The ETag is derived from the stored row's own version, so an unchanged record revalidates to a 304."
        }
      }
    },
    "/v1/progress/{tier}/{subjectSlug}/{classNum}": {
      "put": {
        "tags": [
          "Progress"
        ],
        "operationId": "recordProgress",
        "summary": "Record a class completion",
        "description": "Record that the signed-in learner completed this class, optionally with a quiz score 0-100. A score of 80 or more marks it Mastered; any other score, or none at all, marks it Completed — recording a class always counts as finishing it, exactly as scolavo.com treats the same row.\n\nIdempotent per class: repeating the call overwrites that class's record rather than accumulating. It does not set XP, streak or skill mastery — the site owns those.\n\nThe record is shared with scolavo.com and written under optimistic concurrency. If the learner is active on the site at the same moment, the write can lose the race and return 409 WRITE_CONFLICT with nothing saved. That is retryable and expected, not a fault: retry after the interval in Retry-After.",
        "security": [
          {
            "oauth2": [
              "scolavo-mcp/progress.write"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/pathTier"
          },
          {
            "$ref": "#/components/parameters/pathSubjectSlug"
          },
          {
            "$ref": "#/components/parameters/pathClassNum"
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ProgressWrite"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "What was recorded.",
            "headers": {
              "Cache-Control": {
                "$ref": "#/components/headers/CacheControl"
              },
              "Vary": {
                "$ref": "#/components/headers/Vary"
              },
              "Content-Encoding": {
                "$ref": "#/components/headers/ContentEncoding"
              },
              "X-Scolavo-Content-Version": {
                "$ref": "#/components/headers/ContentVersion"
              },
              "X-Request-Id": {
                "$ref": "#/components/headers/RequestId"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProgressWriteResult"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-scolavo-cost": {
          "typicalBytes": "≈200 B",
          "meters": [
            "calls"
          ],
          "note": "One call. No lesson read; writing needs org membership in good standing, not a content grant for the subject."
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "oauth2": {
        "type": "oauth2",
        "description": "OAuth 2.1 bearer tokens issued by Scolavo's Cognito user pool. Send them as `Authorization: Bearer <token>`.\n\n**client_credentials — use this one for a pipeline.** Machine-to-machine credentials are issued per\nintegration by Scolavo licensing; they are not self-service, because the client id is what binds a\ntoken to your organization and it doubles as the revocation switch. Request only the scopes you need.\nSuch a token has no learner identity, so the progress routes answer 403 LEARNER_REQUIRED — everything\nelse works.\n\n**authorization_code + PKCE — use this when a person is present.** Required for the progress routes,\nwhich act on the signed-in learner's own record. PKCE with `S256` is mandatory. The authorization\nendpoint is Scolavo's own rather than Cognito's: it sanitizes the scope list before forwarding, and\nposting a raw scope list straight to the identity provider fails with invalid_scope.\n\nTokens are short-lived — refresh rather than re-authorizing. Entitlements are re-evaluated on every\nsingle request with no caching, so a suspension or a revoked grant lands on the very next call rather\nthan at token expiry: after an unexpected 403, re-read `GET /v1/license` instead of assuming the\ntoken went bad.",
        "flows": {
          "clientCredentials": {
            "tokenUrl": "https://scolavo-auth.auth.us-east-1.amazoncognito.com/oauth2/token",
            "refreshUrl": "https://scolavo-auth.auth.us-east-1.amazoncognito.com/oauth2/token",
            "scopes": {
              "scolavo-mcp/content.read": "Read curriculum metadata, lessons, transcripts, quizzes and search.",
              "scolavo-mcp/content.download": "Reserved. No REST route requires it today; held for a future bulk-file surface.",
              "scolavo-mcp/content.export": "Download stamped subject/class export bundles.",
              "scolavo-mcp/media.download": "Mint short-lived signed URLs for media assets.",
              "scolavo-mcp/testprep.read": "Read test-prep practice items (the catalog itself is content.read).",
              "scolavo-mcp/progress.read": "Read the signed-in learner's progress.",
              "scolavo-mcp/progress.write": "Record the signed-in learner's class completions."
            }
          },
          "authorizationCode": {
            "authorizationUrl": "https://mcp.scolavo.com/authorize",
            "tokenUrl": "https://scolavo-auth.auth.us-east-1.amazoncognito.com/oauth2/token",
            "refreshUrl": "https://scolavo-auth.auth.us-east-1.amazoncognito.com/oauth2/token",
            "scopes": {
              "scolavo-mcp/content.read": "Read curriculum metadata, lessons, transcripts, quizzes and search.",
              "scolavo-mcp/content.download": "Reserved. No REST route requires it today; held for a future bulk-file surface.",
              "scolavo-mcp/content.export": "Download stamped subject/class export bundles.",
              "scolavo-mcp/media.download": "Mint short-lived signed URLs for media assets.",
              "scolavo-mcp/testprep.read": "Read test-prep practice items (the catalog itself is content.read).",
              "scolavo-mcp/progress.read": "Read the signed-in learner's progress.",
              "scolavo-mcp/progress.write": "Record the signed-in learner's class completions."
            }
          }
        },
        "x-pkce-required": true,
        "x-code-challenge-methods-supported": [
          "S256"
        ],
        "x-discovery": {
          "protectedResourceMetadata": "https://mcp.scolavo.com/.well-known/oauth-protected-resource",
          "authorizationServerMetadata": "https://mcp.scolavo.com/.well-known/oauth-authorization-server"
        }
      }
    },
    "parameters": {
      "tier": {
        "name": "tier",
        "in": "query",
        "required": false,
        "description": "Restrict to one curriculum tier. On the list routes this is the cheapest filter there is — it removes rows before pagination. On the single-subject and export routes it serves a second purpose: a slug can exist in more than one tier, and `tier` is how you disambiguate (without it, an ambiguous slug answers 400 AMBIGUOUS_SUBJECT).",
        "schema": {
          "type": "string",
          "enum": [
            "primary",
            "middle",
            "high",
            "college",
            "topics"
          ]
        }
      },
      "detail": {
        "name": "detail",
        "in": "query",
        "required": false,
        "description": "How much of each record to return. The shape never changes — only how many fields are populated — so a client can widen from brief to full without relearning the response. On a lesson the ladder is roughly 2 KB / 7.5 KB / 26 KB: \"brief\" is structure only (card ids, labels, availability flags), \"standard\" adds bodies, bullets and key terms, \"full\" adds every optional section and lifts the text cap. Crawl with brief, then fetch the few records you actually want.",
        "schema": {
          "type": "string",
          "enum": [
            "brief",
            "standard",
            "full"
          ],
          "default": "standard"
        }
      },
      "include": {
        "name": "include",
        "in": "query",
        "required": false,
        "description": "Comma-separated optional sections to add to a lesson, on top of any `detail` level: quiz, interactives, inlineChecks, narrationText, videoTranscript, media, workedExample. Use this rather than detail=full when you want exactly one of them — include=quiz costs about 2 KB where detail=full costs about 19 KB more than standard. include=media returns ids and types with url:null; mint the URLs in one batch at POST /v1/media/urls.",
        "schema": {
          "type": "array",
          "items": {
            "type": "string",
            "enum": [
              "quiz",
              "interactives",
              "inlineChecks",
              "narrationText",
              "videoTranscript",
              "media",
              "workedExample"
            ]
          },
          "uniqueItems": true
        },
        "style": "form",
        "explode": false
      },
      "cursor": {
        "name": "cursor",
        "in": "query",
        "required": false,
        "description": "Opaque pagination cursor from the previous page's nextCursor. Cursors are bound to the contentVersion they were minted against: if the corpus is republished mid-walk the cursor answers 400 CURSOR_EXPIRED rather than silently skipping or repeating rows. Restart from page 1 and re-key on lessonId.",
        "schema": {
          "type": "string",
          "pattern": "^[A-Za-z0-9_-]{1,512}$"
        }
      },
      "limit": {
        "name": "limit",
        "in": "query",
        "required": false,
        "description": "Rows per page (1-100, default 25). Out of that range is REJECTED with 400 BAD_REQUEST, not clamped: a client that believed it asked for 1,000 rows and silently received 100 would paginate wrong forever, and quietly. Only `calls` is metered per request, so a full walk at limit=100 costs a quarter of the calls of the default for the same bytes.",
        "schema": {
          "type": "integer",
          "minimum": 1,
          "maximum": 100,
          "default": 25
        }
      },
      "q": {
        "name": "q",
        "in": "query",
        "required": true,
        "description": "Free-text query, matched against lesson titles, subtitles, card labels, key-point bullets and quiz questions — NOT full body text. Results are filtered to the subjects your licence grants before pagination.",
        "schema": {
          "type": "string",
          "minLength": 1,
          "maxLength": 200
        }
      },
      "subject": {
        "name": "subject",
        "in": "query",
        "required": false,
        "description": "Restrict search to one subjectSlug (values from GET /v1/subjects). Applied before pagination, so `total` describes the filtered set.",
        "schema": {
          "type": "string",
          "pattern": "^[a-z0-9][a-z0-9-]{1,63}$"
        }
      },
      "subjectSlugQuery": {
        "name": "subjectSlug",
        "in": "query",
        "required": false,
        "description": "Return only this subject's classes.",
        "schema": {
          "type": "string",
          "pattern": "^[a-z0-9][a-z0-9-]{1,63}$"
        }
      },
      "section": {
        "name": "section",
        "in": "query",
        "required": false,
        "description": "Section id WITHIN the chosen test. This enum is the union across all tests, so a valid-looking value can still be wrong for this one (\"rw\" is a Digital SAT / PSAT section, not an ACT one); that answers 404 UNKNOWN_SECTION and the body lists the sections this test does have. The per-test map is published as x-scolavo-sections on the operation — check it locally rather than probing.",
        "schema": {
          "type": "string",
          "enum": [
            "rw",
            "math",
            "english",
            "reading",
            "science",
            "ela"
          ]
        }
      },
      "type": {
        "name": "type",
        "in": "query",
        "required": false,
        "description": "Filter the inventory to one asset kind. Filtering here shrinks the batch you go on to mint, which is where the real cost is.",
        "schema": {
          "type": "string",
          "enum": [
            "video",
            "audio",
            "image"
          ]
        }
      },
      "format": {
        "name": "format",
        "in": "query",
        "required": false,
        "description": "\"zip_url\" (default) returns a short-lived signed URL to a stamped zip — the right choice for a pipeline, because the bundle bytes never pass through this API. \"inline\" returns the same bundle gzipped and base64-encoded in the response body; it is bounded by the API Gateway response limit and a large subject WILL exceed it (413 PAYLOAD_TOO_LARGE).",
        "schema": {
          "type": "string",
          "enum": [
            "zip_url",
            "inline"
          ],
          "default": "zip_url"
        }
      },
      "learnerId": {
        "name": "learnerId",
        "in": "query",
        "required": false,
        "description": "Learner profile under the signed-in account (default \"l1\"). Most accounts have exactly one learner.",
        "schema": {
          "type": "string",
          "pattern": "^[A-Za-z0-9_-]{1,40}$",
          "default": "l1"
        }
      },
      "pathTier": {
        "name": "tier",
        "in": "path",
        "required": true,
        "description": "Curriculum tier — the first segment of a lessonId.",
        "schema": {
          "type": "string",
          "enum": [
            "primary",
            "middle",
            "high",
            "college",
            "topics"
          ]
        }
      },
      "pathSubjectSlug": {
        "name": "subjectSlug",
        "in": "path",
        "required": true,
        "description": "Exact subject identifier, not the display name (e.g. \"hs-mathematics-algebra\", \"language-arts\", \"ai-and-llms\"). Matched EXACTLY — this API does not guess from partial slugs or display names. Slugs are tier-specific and there are 125 of them, so they are not enumerated here: list the ones your licence covers with GET /v1/subjects.",
        "schema": {
          "type": "string",
          "pattern": "^[a-z0-9][a-z0-9-]{1,63}$"
        }
      },
      "pathClassNum": {
        "name": "classNum",
        "in": "path",
        "required": true,
        "description": "1-based class number within the subject. Valid values come from GET /v1/subjects/{subjectSlug}/classes; out of range is 404 LESSON_NOT_FOUND and the body reports the subject's real range.",
        "schema": {
          "type": "integer",
          "minimum": 1,
          "maximum": 999
        }
      },
      "pathTest": {
        "name": "test",
        "in": "path",
        "required": true,
        "description": "Test id from GET /v1/testprep.",
        "schema": {
          "type": "string",
          "enum": [
            "digital-sat",
            "act",
            "psat-nmsqt",
            "caaspp"
          ]
        }
      }
    },
    "headers": {
      "ETag": {
        "description": "Entity tag for this response. It is DERIVED — from the corpus contentVersion, the route and its parameters, and your organization's id, grants and ECLA version — never hashed from the response bytes, because hashing the bytes would mean building the very response the revalidation exists to avoid. Grants are in the key on purpose: without them, adding a subject to your licence would leave you revalidating happily against a stale 304 forever. Send it back as `If-None-Match`.\n\nOne exception, because it has no organization to derive a validator from: GET /v1/openapi.json IS hashed from its own bytes. Treat every tag as opaque either way. Note also that `If-None-Match: *` matches any current representation, so it answers 304 on a GET rather than serving the body.",
        "schema": {
          "type": "string",
          "examples": [
            "\"9f2c1a7be4d05836a1c4e77b2f9d0e13\""
          ]
        }
      },
      "CacheControl": {
        "description": "Content routes are `private, max-age=60, must-revalidate`. Private because every response is scoped and forensically stamped to your organization and must never enter a shared cache. The max-age is a minute rather than a year deliberately: a cached copy is served without ever reaching the licence gate, so the win this API offers is the cheap 304 — still gated, metered and audited — not a silent cache hit. Licence, progress writes and minted URLs are `no-store`. This document is the one `public` response. Operators can lengthen the window per contract.",
        "schema": {
          "type": "string",
          "examples": [
            "private, max-age=60, must-revalidate"
          ]
        }
      },
      "Vary": {
        "description": "`Authorization, Accept-Encoding` on content responses (`Accept, Accept-Encoding` on this document). Authorization because responses differ by organization — a cache keyed on URL alone would serve one licensee's stamped bytes to another. Accept-Encoding because the ETag above is DERIVED and therefore identical for the compressed and uncompressed forms of the same response: without this header a cache would be entitled to hand a gzip body to a client that never asked for one. Present on 304s and errors too, because a 304 tells a cache to reuse a stored representation and so must carry the same variance rules the 200 carried.",
        "schema": {
          "type": "string",
          "examples": [
            "Authorization, Accept-Encoding"
          ]
        }
      },
      "ContentEncoding": {
        "description": "Present only when you sent `Accept-Encoding` and the response was worth compressing: `gzip`, or `br` when you named it. Decode before parsing — most HTTP clients (curl --compressed, Python requests/httpx, Go net/http, Node fetch) do it for you and you will never see this header. Responses under ~384 bytes and the already-compressed `format=inline` export bundles are returned uncompressed, because compressing them costs more bytes than it saves. Never sent to a client that did not ask.",
        "schema": {
          "type": "string",
          "enum": [
            "gzip",
            "br"
          ],
          "examples": [
            "gzip"
          ]
        }
      },
      "ContentVersion": {
        "description": "The corpus build that served this response (`X-Scolavo-Content-Version`). Watch it to detect a republish without parsing bodies: when it moves, cached ETags are stale and outstanding cursors are expired.",
        "schema": {
          "type": "string",
          "examples": [
            "2026-08-04+4fd77ddd"
          ]
        }
      },
      "RetryAfter": {
        "description": "Seconds to wait before retrying. On RATE_LIMITED this is the remainder of the current hour; on QUOTA_EXCEEDED and EXPORT_LIMIT it is the remainder of the MONTH, so treat a large value as \"stop\", not \"spin\". On WRITE_CONFLICT it is about a second.",
        "required": true,
        "schema": {
          "type": "integer",
          "minimum": 1,
          "examples": [
            1847
          ]
        }
      },
      "RequestId": {
        "description": "Correlation id for this request, also echoed in the error body. Quote it when contacting licensing@scolavo.com — it is how a call is found in the audit trail.",
        "schema": {
          "type": "string",
          "examples": [
            "b3f1c2a4-7e2d-4a10-9c31-0a7f5d2e8b44"
          ]
        }
      }
    },
    "responses": {
      "NotModified": {
        "description": "Not modified — your `If-None-Match` matched the current ETag, so the body is not resent.\n\nA 304 costs one `calls` unit (it re-ran the full licence gate) but NO lesson read, and it does not\ntick the enumeration alarm. That asymmetry is the point: re-crawling a subject you already hold\ncosts calls rather than lesson reads, and cannot walk you into the 40%-of-grant auto-suspend.\n\nNot every route offers one. The export routes and the batch mint answer with a freshly signed URL,\nand /v1/license reports live counters — a 304 on any of those would hand back something worse than\nbytes, so they carry no ETag by design.",
        "headers": {
          "ETag": {
            "$ref": "#/components/headers/ETag"
          },
          "Cache-Control": {
            "$ref": "#/components/headers/CacheControl"
          },
          "Vary": {
            "$ref": "#/components/headers/Vary"
          },
          "X-Scolavo-Content-Version": {
            "$ref": "#/components/headers/ContentVersion"
          },
          "X-Request-Id": {
            "$ref": "#/components/headers/RequestId"
          }
        }
      },
      "BadRequest": {
        "description": "The request is malformed: a bad parameter value, an id not in the documented format, an expired cursor, an oversized batch, or an ambiguous slug. Nothing was served and no content meter moved. The body names the offending parameter and, for an enum, what was allowed.\n\nCodes: INVALID_PARAMETER, BAD_REQUEST, BAD_LESSON_ID, BAD_MEDIA_ID, CURSOR_EXPIRED, BATCH_TOO_LARGE, AMBIGUOUS_SUBJECT.",
        "headers": {
          "X-Request-Id": {
            "$ref": "#/components/headers/RequestId"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            }
          }
        }
      },
      "Unauthorized": {
        "description": "The bearer token is missing, malformed, expired, or not verifiable. The `WWW-Authenticate` header points at this resource's RFC 9728 metadata so a client can discover where to get a valid one. The body carries no content of any kind.\n\nCodes: invalid_token, invalid_request.",
        "headers": {
          "X-Request-Id": {
            "$ref": "#/components/headers/RequestId"
          },
          "WWW-Authenticate": {
            "description": "RFC 9728 challenge naming this resource's protected-resource metadata document.",
            "required": true,
            "schema": {
              "type": "string",
              "examples": [
                "Bearer resource_metadata=\"https://mcp.scolavo.com/.well-known/oauth-protected-resource\", error=\"invalid_token\""
              ]
            }
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            }
          }
        }
      },
      "Forbidden": {
        "description": "The token is valid but the request is not permitted: it lacks the required scope, or the licence does not cover this content (or is suspended, expired, or pending a new ECLA version). Retrying will not help until something changes. GET /v1/license answers every one of these and stays reachable even when the licence is not in good standing.\n\nCodes: insufficient_scope, NO_ORGANIZATION, ORG_SUSPENDED, LICENSE_EXPIRED, LICENSE_REQUIRED, TIER_NOT_LICENSED, CONTENT_NOT_GRANTED, LEARNER_REQUIRED.",
        "headers": {
          "X-Request-Id": {
            "$ref": "#/components/headers/RequestId"
          },
          "WWW-Authenticate": {
            "description": "Present on insufficient_scope, naming the scope the route requires (also in the body as `scope`).",
            "schema": {
              "type": "string",
              "examples": [
                "Bearer resource_metadata=\"…\", error=\"insufficient_scope\", scope=\"scolavo-mcp/media.download\""
              ]
            }
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            }
          }
        }
      },
      "NotFound": {
        "description": "The identifier names nothing inside your licence. Bodies here are actionable rather than merely accurate: an out-of-range classNum reports the subject's real class range, and an unknown section lists the sections the test does have.\n\nCodes: UNKNOWN_SUBJECT, LESSON_NOT_FOUND, UNKNOWN_MEDIA, UNKNOWN_TEST, UNKNOWN_SECTION, NOT_FOUND.",
        "headers": {
          "X-Request-Id": {
            "$ref": "#/components/headers/RequestId"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            }
          }
        }
      },
      "Conflict": {
        "description": "A concurrent writer updated the learner record between this request's read and its write, and nothing was saved. Retryable — reissue after the interval in Retry-After.\n\nCodes: WRITE_CONFLICT.",
        "headers": {
          "X-Request-Id": {
            "$ref": "#/components/headers/RequestId"
          },
          "Retry-After": {
            "$ref": "#/components/headers/RetryAfter"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            }
          }
        }
      },
      "PayloadTooLarge": {
        "description": "The response would exceed the API Gateway response limit, so it is refused here with an explanation rather than by the gateway with an opaque 502. In practice this means `format=inline` on a large subject: use `format=zip_url`, which sends the bundle out of band.\n\nCodes: PAYLOAD_TOO_LARGE.",
        "headers": {
          "X-Request-Id": {
            "$ref": "#/components/headers/RequestId"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            }
          }
        }
      },
      "TooManyRequests": {
        "description": "A meter refused the call: an hourly velocity cap (RATE_LIMITED) or a monthly quota (QUOTA_EXCEEDED, EXPORT_LIMIT). Read `Retry-After` before retrying — an exhausted monthly quota will not clear this hour, and hammering it burns nothing but your own call quota.\n\nCodes: RATE_LIMITED, QUOTA_EXCEEDED, EXPORT_LIMIT.",
        "headers": {
          "X-Request-Id": {
            "$ref": "#/components/headers/RequestId"
          },
          "Retry-After": {
            "$ref": "#/components/headers/RetryAfter"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            }
          }
        }
      },
      "ServerError": {
        "description": "An unexpected server fault. Safe to retry once; if it persists, contact licensing@scolavo.com quoting the X-Request-Id.\n\nCodes: INTERNAL.",
        "headers": {
          "X-Request-Id": {
            "$ref": "#/components/headers/RequestId"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            }
          }
        }
      }
    },
    "schemas": {
      "TierId": {
        "type": "string",
        "enum": [
          "primary",
          "middle",
          "high",
          "college",
          "topics"
        ],
        "description": "Curriculum tier. Five values, stable across corpus builds, so they are enumerated here.",
        "examples": [
          "high"
        ]
      },
      "SubjectSlug": {
        "type": "string",
        "pattern": "^[a-z0-9][a-z0-9-]{1,63}$",
        "maxLength": 64,
        "description": "Exact subject identifier, not the display name — e.g. \"hs-mathematics-algebra\", \"language-arts\", \"ai-and-llms\". Slugs are tier-specific and there are 125 of them, so they are described rather than enumerated: list the ones your licence covers with GET /v1/subjects.",
        "examples": [
          "hs-mathematics-algebra"
        ]
      },
      "LessonId": {
        "type": "string",
        "pattern": "^(primary|middle|high|college|topics)/[a-z0-9-]+/[1-9][0-9]{0,2}$",
        "description": "Lesson identifier, \"{tier}/{subjectSlug}/{classNum}\". Always take one from a class list or a search hit; constructing one by hand is how you get a 404 for a class that exists under a different slug.",
        "examples": [
          "high/hs-mathematics-algebra/1"
        ]
      },
      "MediaId": {
        "type": "string",
        "pattern": "^(primary|middle|high|college|topics)/[a-z0-9-]+/[1-9][0-9]{0,2}#[A-Za-z0-9._-]+#(video|audio|image)$",
        "description": "Media asset identifier, \"{lessonId}#{cardId}#{type}\". Treat it as opaque and pass it back exactly as returned by the media route; never assemble one.",
        "examples": [
          "high/hs-mathematics-algebra/1#problem#audio"
        ]
      },
      "ContentVersion": {
        "type": "string",
        "description": "Corpus build identifier, \"{date}+{hash}\". Every cursor and ETag is bound to it.",
        "examples": [
          "2026-08-04+4fd77ddd"
        ]
      },
      "LicenseEnvelope": {
        "type": "object",
        "description": "Per-response licence stamp, on every content response. `lic` names the licence the bytes were served under; `tag` is an HMAC over (organization, scope) that identifies both in a payload that has been copied elsewhere. It is two fields on purpose — the constant attribution sentence rides in each export bundle's LICENSE.txt rather than on every response.",
        "required": [
          "lic",
          "tag"
        ],
        "properties": {
          "lic": {
            "type": "string",
            "description": "Licence identifier, \"ECLA-{version}-{orgId}\".",
            "examples": [
              "ECLA-1-org-453b2b2bdf"
            ]
          },
          "tag": {
            "type": "string",
            "description": "Forensic tag identifying organization and scope.",
            "examples": [
              "7QK3ZB2WMA"
            ]
          }
        },
        "examples": [
          {
            "lic": "ECLA-1-org-453b2b2bdf",
            "tag": "7QK3ZB2WMA"
          }
        ]
      },
      "LicenseEnvelopeFull": {
        "type": "object",
        "description": "The full envelope, carried INSIDE export bundles (on each stamped class) rather than on API responses.",
        "required": [
          "licenseId",
          "org",
          "attribution",
          "tag"
        ],
        "properties": {
          "licenseId": {
            "type": "string",
            "description": "Licence identifier.",
            "examples": [
              "ECLA-1-org-453b2b2bdf"
            ]
          },
          "org": {
            "type": "string",
            "description": "Licensed organization name.",
            "examples": [
              "Vizlings, Inc."
            ]
          },
          "attribution": {
            "type": "string",
            "description": "Required attribution sentence.",
            "examples": [
              "© Scolavo — licensed for internal use under ECLA v1"
            ]
          },
          "tag": {
            "type": "string",
            "description": "Forensic tag.",
            "examples": [
              "7QK3ZB2WMA"
            ]
          }
        }
      },
      "Page": {
        "type": "object",
        "description": "The pagination envelope shared by EVERY list response in this API. The rows are always in `items` — never `subjects`, `hits` or `classes` — so one pagination helper works against every route.\n\n`total` is always the count WITHIN your licence, never within the corpus, so it is safe as a progress denominator. Walk on `hasMore`, not on the presence of `nextCursor`: the cursor is absent on the last page.",
        "required": [
          "items",
          "total",
          "pageNum",
          "totalPages",
          "hasMore",
          "contentVersion",
          "_license"
        ],
        "properties": {
          "items": {
            "type": "array",
            "description": "The rows for this page. The element type depends on the route."
          },
          "total": {
            "type": "integer",
            "description": "Matching rows across all pages, within your licence.",
            "examples": [
              125
            ]
          },
          "pageNum": {
            "type": "integer",
            "description": "1-based page number.",
            "minimum": 1,
            "examples": [
              1
            ]
          },
          "totalPages": {
            "type": "integer",
            "description": "Pages at the current limit.",
            "minimum": 1,
            "examples": [
              5
            ]
          },
          "hasMore": {
            "type": "boolean",
            "description": "Whether another page exists."
          },
          "nextCursor": {
            "type": "string",
            "description": "Pass as `cursor` for the next page. ABSENT on the last page. Bound to contentVersion — a republish mid-walk expires it with 400 CURSOR_EXPIRED rather than silently skipping rows.",
            "examples": [
              "eyJvIjoyNSwidiI6IjIwMjYtMDgtMDQrNGZkNzdkZGQifQ"
            ]
          },
          "contentVersion": {
            "type": "string",
            "description": "Corpus build that produced this response, e.g. \"2026-08-04+4fd77ddd\". Also returned as the X-Scolavo-Content-Version header, and an input to every ETag: when it changes, cached ETags are stale and outstanding cursors are expired."
          },
          "_license": {
            "$ref": "#/components/schemas/LicenseEnvelope"
          }
        }
      },
      "Tier": {
        "type": "object",
        "description": "One curriculum tier and how many licensed subjects it holds.",
        "required": [
          "tier",
          "label",
          "count"
        ],
        "properties": {
          "tier": {
            "$ref": "#/components/schemas/TierId"
          },
          "label": {
            "type": "string",
            "description": "Display name.",
            "examples": [
              "High School"
            ]
          },
          "count": {
            "type": "integer",
            "description": "Subjects in this tier that your licence covers — not the number in the corpus.",
            "examples": [
              29
            ]
          }
        },
        "examples": [
          {
            "tier": "high",
            "label": "High School",
            "count": 29
          }
        ]
      },
      "TierPage": {
        "description": "The licensed tiers. Returned in the standard page envelope for consistency, though it is always a single page.",
        "allOf": [
          {
            "$ref": "#/components/schemas/Page"
          },
          {
            "type": "object",
            "properties": {
              "items": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/Tier"
                }
              }
            }
          }
        ]
      },
      "Coverage": {
        "type": "object",
        "description": "Fraction of the subject's classes (0-1) carrying each asset kind. Use it to decide whether a media or transcript walk is worth starting: coverage 0 means every one of those calls returns empty and still costs a lesson read.",
        "properties": {
          "video": {
            "type": "number",
            "minimum": 0,
            "maximum": 1,
            "examples": [
              1
            ]
          },
          "narration": {
            "type": "number",
            "minimum": 0,
            "maximum": 1,
            "examples": [
              1
            ]
          },
          "transcripts": {
            "type": "number",
            "minimum": 0,
            "maximum": 1,
            "examples": [
              1
            ]
          }
        },
        "examples": [
          {
            "video": 1,
            "narration": 1,
            "transcripts": 1
          }
        ]
      },
      "Difficulty": {
        "type": "object",
        "description": "Authored difficulty rating.",
        "required": [
          "level",
          "label"
        ],
        "properties": {
          "level": {
            "type": "integer",
            "description": "1 (gentlest) to 5 (hardest).",
            "minimum": 1,
            "maximum": 5,
            "examples": [
              2
            ]
          },
          "label": {
            "type": "string",
            "description": "Human label.",
            "examples": [
              "Easy"
            ]
          }
        },
        "examples": [
          {
            "level": 2,
            "label": "Easy"
          }
        ]
      },
      "SubjectRef": {
        "type": "object",
        "description": "A pointer to another subject, used by prerequisites and related subjects.",
        "required": [
          "subjectSlug",
          "tier"
        ],
        "properties": {
          "subjectSlug": {
            "$ref": "#/components/schemas/SubjectSlug"
          },
          "tier": {
            "$ref": "#/components/schemas/TierId"
          },
          "name": {
            "type": "string",
            "description": "Display name.",
            "examples": [
              "Mathematics"
            ]
          },
          "reason": {
            "type": "string",
            "description": "On a prerequisite: why it comes first. Absent on related subjects.",
            "examples": [
              "builds on primary arithmetic fluency (addition, subtraction, multiplication, division)"
            ]
          }
        },
        "examples": [
          {
            "subjectSlug": "mathematics",
            "tier": "primary",
            "reason": "builds on primary arithmetic fluency"
          }
        ]
      },
      "SubjectSummary": {
        "type": "object",
        "description": "One subject as a list row. `detail` decides how many optional fields are populated — the shape never changes, so widening never breaks a parser. brief: identifiers, name and class count. standard: adds coverage and difficulty. full: adds tags, prerequisites and related subjects, which are identical on every page of a walk and are better fetched once from GET /v1/subjects/{subjectSlug}.",
        "required": [
          "subjectSlug",
          "tier",
          "name",
          "classes"
        ],
        "properties": {
          "subjectSlug": {
            "$ref": "#/components/schemas/SubjectSlug"
          },
          "tier": {
            "$ref": "#/components/schemas/TierId"
          },
          "name": {
            "type": "string",
            "description": "Display name.",
            "examples": [
              "Algebra"
            ]
          },
          "classes": {
            "type": "integer",
            "description": "Number of classes in the subject — a COUNT, not the list. The list is at GET /v1/subjects/{subjectSlug}/classes.",
            "examples": [
              30
            ]
          },
          "coverage": {
            "$ref": "#/components/schemas/Coverage"
          },
          "difficulty": {
            "$ref": "#/components/schemas/Difficulty"
          },
          "tags": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "detail=full only.",
            "examples": [
              [
                "equations",
                "functions",
                "graphing"
              ]
            ]
          },
          "prerequisites": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/SubjectRef"
            },
            "description": "detail=full only. Subjects to study first."
          },
          "relatedSubjects": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/SubjectRef"
            },
            "description": "detail=full only."
          }
        },
        "examples": [
          {
            "subjectSlug": "hs-mathematics-algebra",
            "tier": "high",
            "name": "Algebra",
            "classes": 30,
            "coverage": {
              "video": 1,
              "narration": 1,
              "transcripts": 1
            },
            "difficulty": {
              "level": 3,
              "label": "Intermediate"
            }
          }
        ]
      },
      "SubjectPage": {
        "description": "A page of subjects.",
        "allOf": [
          {
            "$ref": "#/components/schemas/Page"
          },
          {
            "type": "object",
            "properties": {
              "items": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/SubjectSummary"
                }
              }
            }
          }
        ]
      },
      "SubjectDetail": {
        "type": "object",
        "description": "One subject: the class COUNT plus its full subject-graph metadata, and a link to the class list. The class list is not inlined — follow `classesUrl`.",
        "required": [
          "subjectSlug",
          "tier",
          "name",
          "classes",
          "classesUrl",
          "contentVersion",
          "_license"
        ],
        "properties": {
          "subjectSlug": {
            "$ref": "#/components/schemas/SubjectSlug"
          },
          "tier": {
            "$ref": "#/components/schemas/TierId"
          },
          "name": {
            "type": "string",
            "description": "Display name.",
            "examples": [
              "Algebra"
            ]
          },
          "level": {
            "type": "string",
            "description": "Human label for the tier, as printed on the site.",
            "examples": [
              "High School"
            ]
          },
          "classes": {
            "type": "integer",
            "description": "Number of classes.",
            "examples": [
              30
            ]
          },
          "classCount": {
            "type": "integer",
            "description": "Alias of `classes`, carried because the list rows call it `classes` and the lesson objects call it `totalClasses`. Always identical to `classes`; either is safe to read.",
            "examples": [
              30
            ]
          },
          "classesUrl": {
            "type": "string",
            "description": "Path to the class list for this subject.",
            "examples": [
              "/v1/subjects/hs-mathematics-algebra/classes"
            ]
          },
          "coverage": {
            "$ref": "#/components/schemas/Coverage"
          },
          "difficulty": {
            "$ref": "#/components/schemas/Difficulty"
          },
          "tags": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "examples": [
              [
                "equations",
                "functions",
                "graphing"
              ]
            ]
          },
          "prerequisites": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/SubjectRef"
            },
            "description": "Subjects to study first, in the order a learner should take them."
          },
          "relatedSubjects": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/SubjectRef"
            }
          },
          "contentVersion": {
            "type": "string",
            "description": "Corpus build that produced this response, e.g. \"2026-08-04+4fd77ddd\". Also returned as the X-Scolavo-Content-Version header, and an input to every ETag: when it changes, cached ETags are stale and outstanding cursors are expired."
          },
          "_license": {
            "$ref": "#/components/schemas/LicenseEnvelope"
          }
        }
      },
      "ClassRef": {
        "type": "object",
        "description": "One class within a subject. `lessonId` is ready to pass to the lesson routes; the flags tell you whether the transcript and media routes will return anything.",
        "required": [
          "lessonId",
          "classNum",
          "title"
        ],
        "properties": {
          "lessonId": {
            "$ref": "#/components/schemas/LessonId"
          },
          "classNum": {
            "type": "integer",
            "description": "1-based position in the subject.",
            "minimum": 1,
            "examples": [
              1
            ]
          },
          "title": {
            "type": "string",
            "description": "Class title.",
            "examples": [
              "What is algebra — and why do we use letters instead of numbers?"
            ]
          },
          "hasVideo": {
            "type": "boolean",
            "description": "Any card carries a video clip."
          },
          "hasNarration": {
            "type": "boolean",
            "description": "Any card carries narration audio."
          },
          "hasTranscript": {
            "type": "boolean",
            "description": "A spoken transcript exists. False means the transcript route returns an empty string and still costs a lesson read."
          }
        },
        "examples": [
          {
            "lessonId": "high/hs-mathematics-algebra/1",
            "classNum": 1,
            "title": "What is algebra — and why do we use letters instead of numbers?",
            "hasVideo": true,
            "hasNarration": true,
            "hasTranscript": true
          }
        ]
      },
      "ClassPage": {
        "description": "A page of classes within one subject.",
        "allOf": [
          {
            "$ref": "#/components/schemas/Page"
          },
          {
            "type": "object",
            "required": [
              "subjectSlug",
              "tier"
            ],
            "properties": {
              "items": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/ClassRef"
                }
              },
              "subjectSlug": {
                "$ref": "#/components/schemas/SubjectSlug"
              },
              "tier": {
                "$ref": "#/components/schemas/TierId"
              }
            }
          }
        ]
      },
      "MediaItem": {
        "type": "object",
        "description": "One media asset. `url` is ALWAYS null here — not a gap but the licence boundary: URLs are minted at POST /v1/media/urls under the media.download scope and the downloads quota, or delivered inside an export bundle's media manifest.",
        "required": [
          "mediaId",
          "type",
          "url"
        ],
        "properties": {
          "mediaId": {
            "$ref": "#/components/schemas/MediaId"
          },
          "type": {
            "type": "string",
            "enum": [
              "video",
              "audio",
              "image"
            ],
            "description": "Asset kind.",
            "examples": [
              "audio"
            ]
          },
          "url": {
            "type": [
              "string",
              "null"
            ],
            "description": "Always null. Mint one at POST /v1/media/urls."
          },
          "cardId": {
            "type": "string",
            "description": "Card the asset belongs to. Present on the media route; omitted inside a lesson's cards, where the card is already known.",
            "examples": [
              "problem"
            ]
          }
        },
        "examples": [
          {
            "mediaId": "high/hs-mathematics-algebra/1#problem#audio",
            "type": "audio",
            "cardId": "problem",
            "url": null
          }
        ]
      },
      "LessonCard": {
        "type": "object",
        "description": "One card in a class — the unit a lesson is built from. Ids and flags are present at every `detail` level; `body`, `bullets` and `illustrationAlt` appear from standard; the narration and video-transcript fields only when requested via `include` (or implied by detail=full).",
        "required": [
          "id",
          "label"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "Card id, unique within the lesson and stable across corpus builds.",
            "examples": [
              "problem"
            ]
          },
          "label": {
            "type": "string",
            "description": "Card heading as shown to a learner.",
            "examples": [
              "01 · The puzzle"
            ]
          },
          "body": {
            "type": "string",
            "description": "The written lesson text, in markdown. Bold spans mark the terms surfaced as `keyTerms`. Present from detail=standard, and subject to the per-detail text cap.",
            "examples": [
              "Arithmetic is a powerful tool, but it only works when you already know all the numbers."
            ]
          },
          "bullets": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Key points for this card. Present from detail=standard.",
            "examples": [
              [
                "Arithmetic: compute 4 × 7 = 28. You know everything going in."
              ]
            ]
          },
          "hasVideo": {
            "type": "boolean",
            "description": "A video clip exists for this card."
          },
          "hasNarration": {
            "type": "boolean",
            "description": "Narration audio exists for this card."
          },
          "hasTranscript": {
            "type": "boolean",
            "description": "A spoken transcript exists. Where `narrationText` is absent but this is true, the card BODY is the narration — deliberate de-duplication, not missing data."
          },
          "hasVideoTranscript": {
            "type": "boolean",
            "description": "The card's video clip has an authored voiceover line."
          },
          "hasIllustration": {
            "type": "boolean",
            "description": "An illustration exists for this card."
          },
          "illustrationAlt": {
            "type": "string",
            "description": "Server-derived alt text describing the illustration at concept level. From detail=standard.",
            "examples": [
              "Illustration for “The puzzle”: Arithmetic is a powerful tool, but it only works when you already know all the numbers."
            ]
          },
          "narrationText": {
            "type": "string",
            "description": "The spoken script where it differs from the body. include=narrationText."
          },
          "videoTranscript": {
            "type": "string",
            "description": "The video clip's spoken line. include=videoTranscript."
          },
          "media": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/MediaItem"
            },
            "description": "Asset ids and types, urls null. include=media."
          }
        }
      },
      "KeyTerm": {
        "type": "object",
        "description": "A term the class defines, extracted server-side from the bold spans in the card bodies. Free — no separate call, and nothing to reconcile against the text.",
        "required": [
          "term",
          "definition"
        ],
        "properties": {
          "term": {
            "type": "string",
            "description": "The term.",
            "examples": [
              "variable"
            ]
          },
          "definition": {
            "type": "string",
            "description": "The sentence from the lesson that defines it (max 400 characters).",
            "examples": [
              "A variable is a placeholder for one specific number that makes the equation true."
            ]
          },
          "firstAppearsInCard": {
            "type": "string",
            "description": "Card id where it is first defined.",
            "examples": [
              "definition"
            ]
          }
        }
      },
      "QuizItem": {
        "type": "object",
        "description": "One end-of-class quiz question. `answer` is a zero-based INDEX into `options`, not the option text.",
        "required": [
          "q",
          "options",
          "answer"
        ],
        "properties": {
          "q": {
            "type": "string",
            "description": "The question.",
            "examples": [
              "Who gave algebra its name, and where did he work?"
            ]
          },
          "options": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "minItems": 2,
            "examples": [
              [
                "Euclid, in ancient Alexandria",
                "Al-Khwarizmi, at the House of Wisdom in Baghdad",
                "Pythagoras, on the island of Samos",
                "Fibonacci, in medieval Italy"
              ]
            ]
          },
          "answer": {
            "type": "integer",
            "description": "Zero-based index of the correct option.",
            "minimum": 0,
            "examples": [
              1
            ]
          }
        },
        "examples": [
          {
            "q": "Who gave algebra its name, and where did he work?",
            "options": [
              "Euclid, in ancient Alexandria",
              "Al-Khwarizmi, at the House of Wisdom in Baghdad",
              "Pythagoras, on the island of Samos",
              "Fibonacci, in medieval Italy"
            ],
            "answer": 1
          }
        ]
      },
      "InlineCheck": {
        "type": "object",
        "description": "A mid-lesson comprehension check, shown after a specific card. Unlike a quiz item it carries graduated hints and a rationale, which is what makes it usable for tutoring rather than only for grading.",
        "required": [
          "q",
          "options",
          "answer"
        ],
        "properties": {
          "afterCard": {
            "type": "string",
            "description": "Card id this check follows.",
            "examples": [
              "definition"
            ]
          },
          "q": {
            "type": "string",
            "description": "The question.",
            "examples": [
              "In the equation 2x + 3 = 11, what does the variable x represent?"
            ]
          },
          "options": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "minItems": 2
          },
          "answer": {
            "type": "integer",
            "description": "Zero-based index of the correct option.",
            "minimum": 0,
            "examples": [
              1
            ]
          },
          "hints": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Progressive hints, gentlest first.",
            "examples": [
              [
                "The class insists x is not a “mysterious unknown” — re-read what it IS instead."
              ]
            ]
          },
          "why": {
            "type": "string",
            "description": "Why the correct option is correct, in the lesson's own terms.",
            "examples": [
              "The class defines a variable as a placeholder for a specific number that satisfies the equation."
            ]
          }
        }
      },
      "Analogy": {
        "type": "object",
        "description": "An authored analogy for a concept in this class.",
        "required": [
          "concept",
          "analogy"
        ],
        "properties": {
          "concept": {
            "type": "string",
            "description": "The concept being explained.",
            "examples": [
              "phonemic awareness (letter sounds)"
            ]
          },
          "analogy": {
            "type": "string",
            "description": "The analogy.",
            "examples": [
              "Each letter is like a musical note — string letter sounds together in order and you get a whole word, just like notes become a song."
            ]
          }
        }
      },
      "Lesson": {
        "type": "object",
        "description": "One class lesson. Optional sections appear according to `detail` and `include`; the shape is otherwise constant, so a client written against detail=brief keeps parsing detail=full unchanged.",
        "required": [
          "lessonId",
          "tier",
          "subjectSlug",
          "classNum",
          "title",
          "cards",
          "cardCount",
          "contentVersion",
          "_license"
        ],
        "properties": {
          "lessonId": {
            "$ref": "#/components/schemas/LessonId"
          },
          "tier": {
            "$ref": "#/components/schemas/TierId"
          },
          "level": {
            "type": "string",
            "description": "Human label for the tier.",
            "examples": [
              "High School"
            ]
          },
          "subject": {
            "type": "string",
            "description": "Subject display name.",
            "examples": [
              "Mathematics - Algebra"
            ]
          },
          "subjectSlug": {
            "$ref": "#/components/schemas/SubjectSlug"
          },
          "classNum": {
            "type": "integer",
            "description": "1-based class number.",
            "minimum": 1,
            "examples": [
              1
            ]
          },
          "totalClasses": {
            "type": "integer",
            "description": "Classes in the subject.",
            "examples": [
              30
            ]
          },
          "title": {
            "type": "string",
            "description": "Class title.",
            "examples": [
              "What is algebra — and why do we use letters instead of numbers?"
            ]
          },
          "subtitle": {
            "type": "string",
            "description": "One-line framing.",
            "examples": [
              "The moment arithmetic became a language for describing patterns, not just computing answers"
            ]
          },
          "url": {
            "type": "string",
            "description": "Public scolavo.com page for this class.",
            "format": "uri",
            "examples": [
              "https://www.scolavo.com/high/hs-mathematics-algebra?class=1"
            ]
          },
          "estimatedMinutes": {
            "type": "integer",
            "description": "Server-derived study time, from word count, video cards and question count.",
            "examples": [
              14
            ]
          },
          "cardCount": {
            "type": "integer",
            "description": "Number of cards, at every detail level — so a brief crawl already knows the size of the full record. Median 13.",
            "examples": [
              13
            ]
          },
          "keyTerms": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/KeyTerm"
            },
            "description": "Terms the class defines. From detail=standard."
          },
          "cards": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/LessonCard"
            },
            "description": "The class content, in order."
          },
          "quiz": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/QuizItem"
            },
            "description": "include=quiz, or detail=full."
          },
          "inlineChecks": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/InlineCheck"
            },
            "description": "include=inlineChecks, or detail=full."
          },
          "interactives": {
            "type": "array",
            "items": {
              "type": "object"
            },
            "description": "include=interactives, or detail=full. Widget configurations; the shape varies by widget type and is not modelled here."
          },
          "workedExample": {
            "type": "object",
            "description": "include=workedExample, or detail=full. A step-by-step example; the shape varies by subject."
          },
          "analogies": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Analogy"
            },
            "description": "Authored analogies for this class, where they exist. From detail=standard."
          },
          "truncated": {
            "type": "boolean",
            "description": "Present and true when text was cut to fit the per-detail cap. Said out loud rather than silently storing a half-record: use detail=full or an export bundle for the complete text."
          },
          "contentVersion": {
            "type": "string",
            "description": "Corpus build that produced this response, e.g. \"2026-08-04+4fd77ddd\". Also returned as the X-Scolavo-Content-Version header, and an input to every ETag: when it changes, cached ETags are stale and outstanding cursors are expired."
          },
          "_license": {
            "$ref": "#/components/schemas/LicenseEnvelope"
          }
        }
      },
      "TranscriptSegment": {
        "type": "object",
        "description": "One card's spoken content. Cards with neither narration nor a video voiceover are omitted entirely.",
        "required": [
          "cardId"
        ],
        "properties": {
          "cardId": {
            "type": "string",
            "description": "Card id.",
            "examples": [
              "problem"
            ]
          },
          "label": {
            "type": "string",
            "description": "Card heading.",
            "examples": [
              "01 · The puzzle"
            ]
          },
          "narration": {
            "type": "string",
            "description": "Spoken narration — the distinct narration script where one exists, otherwise the card body, which IS the text that was narrated."
          },
          "videoTranscript": {
            "type": "string",
            "description": "The card's video voiceover line."
          }
        }
      },
      "Transcript": {
        "description": "The spoken text of one class as per-card rows. Pass include=markdown to ALSO get it as one joined string — the same words again, so it is opt-in, not default.",
        "allOf": [
          {
            "$ref": "#/components/schemas/Page"
          },
          {
            "type": "object",
            "required": [
              "lessonId"
            ],
            "properties": {
              "items": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/TranscriptSegment"
                }
              },
              "lessonId": {
                "$ref": "#/components/schemas/LessonId"
              },
              "title": {
                "type": "string",
                "description": "Class title."
              },
              "transcript": {
                "type": "string",
                "description": "The whole spoken text as one markdown string, sectioned by card. Empty when the class has neither narration nor a video voiceover.",
                "examples": [
                  "# What is algebra…\n\n## Cold open (narration)\n\nWelcome to algebra…"
                ]
              },
              "hasNarration": {
                "type": "boolean",
                "description": "Any card is narrated."
              },
              "hasVideoTranscript": {
                "type": "boolean",
                "description": "Any card has a video voiceover."
              },
              "truncated": {
                "type": "boolean",
                "description": "Present and true when the joined transcript was cut."
              },
              "omittedChars": {
                "type": "integer",
                "description": "How many characters were cut, when truncated."
              }
            }
          }
        ]
      },
      "QuizDoc": {
        "description": "Assessment content for one class: the quiz in `items`, plus the inline checks.",
        "allOf": [
          {
            "$ref": "#/components/schemas/Page"
          },
          {
            "type": "object",
            "required": [
              "lessonId",
              "inlineChecks"
            ],
            "properties": {
              "items": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/QuizItem"
                }
              },
              "lessonId": {
                "$ref": "#/components/schemas/LessonId"
              },
              "inlineChecks": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/InlineCheck"
                }
              }
            }
          }
        ]
      },
      "MediaList": {
        "description": "The media inventory for one class. Median 31 assets.",
        "allOf": [
          {
            "$ref": "#/components/schemas/Page"
          },
          {
            "type": "object",
            "required": [
              "lessonId"
            ],
            "properties": {
              "items": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/MediaItem"
                }
              },
              "lessonId": {
                "$ref": "#/components/schemas/LessonId"
              },
              "note": {
                "type": "string",
                "description": "Reminder of where URLs come from.",
                "examples": [
                  "Listing never mints — that is what makes listing cheap. Mint up to 100 ids in ONE call with POST /v1/media/urls (metered as one download per id)."
                ]
              }
            }
          }
        ]
      },
      "MintRequest": {
        "type": "object",
        "description": "A batch URL-minting request.",
        "required": [
          "mediaIds"
        ],
        "properties": {
          "mediaIds": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/MediaId"
            },
            "minItems": 1,
            "maxItems": 100,
            "description": "Up to 100 media ids, exactly as returned by the media route. Ids may span lessons and subjects, but every subject they touch must be granted and every id must exist: the batch is all-or-nothing.",
            "examples": [
              [
                "high/hs-mathematics-algebra/1#problem#audio",
                "high/hs-mathematics-algebra/1#problem#image"
              ]
            ]
          },
          "expiresIn": {
            "type": "integer",
            "description": "Seconds until the URLs expire (60-900, default 900; up to 3600 with the longUrls override). Values above your cap are clamped, not rejected.",
            "minimum": 60,
            "maximum": 3600,
            "default": 900,
            "examples": [
              900
            ]
          }
        },
        "examples": [
          {
            "mediaIds": [
              "high/hs-mathematics-algebra/1#problem#audio",
              "high/hs-mathematics-algebra/1#problem#image"
            ],
            "expiresIn": 900
          }
        ]
      },
      "SignedUrl": {
        "type": "object",
        "description": "One minted, time-limited download URL. Do not cache or store these — re-mint instead; a stored URL is a licence leak with an expiry date on it.",
        "required": [
          "mediaId",
          "type",
          "lessonId",
          "url",
          "expiresAt"
        ],
        "properties": {
          "mediaId": {
            "$ref": "#/components/schemas/MediaId"
          },
          "type": {
            "type": "string",
            "enum": [
              "video",
              "audio",
              "image"
            ],
            "examples": [
              "audio"
            ]
          },
          "lessonId": {
            "$ref": "#/components/schemas/LessonId"
          },
          "url": {
            "type": "string",
            "description": "The signed URL.",
            "format": "uri",
            "examples": [
              "https://scolavo-site.s3.amazonaws.com/media/high/v1/hs-mathematics-algebra/class-what-is-algebra__problem.mp3?X-Amz-Expires=900&X-Amz-Signature=…"
            ]
          },
          "expiresAt": {
            "type": "string",
            "description": "When the URL stops working.",
            "format": "date-time",
            "examples": [
              "2026-08-20T12:15:00.000Z"
            ]
          }
        }
      },
      "MintResponse": {
        "type": "object",
        "description": "The minted URLs, in the same order as the ids in the request, so results can be zipped against the input positionally. There is no partial-success shape: if this returns 200, every requested id was minted.",
        "required": [
          "items",
          "count",
          "expiresAt",
          "contentVersion",
          "_license"
        ],
        "properties": {
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/SignedUrl"
            }
          },
          "count": {
            "type": "integer",
            "description": "How many URLs were minted — equal to the number of ids requested.",
            "examples": [
              2
            ]
          },
          "expiresAt": {
            "type": "string",
            "description": "Expiry shared by every URL in this batch.",
            "format": "date-time",
            "examples": [
              "2026-08-20T12:15:00.000Z"
            ]
          },
          "contentVersion": {
            "type": "string",
            "description": "Corpus build that produced this response, e.g. \"2026-08-04+4fd77ddd\". Also returned as the X-Scolavo-Content-Version header, and an input to every ETag: when it changes, cached ETags are stale and outstanding cursors are expired."
          },
          "_license": {
            "$ref": "#/components/schemas/LicenseEnvelope"
          }
        }
      },
      "SearchHit": {
        "type": "object",
        "description": "One search result. `matchedField` says where the term was found, which is usually enough to decide whether the hit is worth a lesson read.",
        "required": [
          "lessonId",
          "title"
        ],
        "properties": {
          "lessonId": {
            "$ref": "#/components/schemas/LessonId"
          },
          "title": {
            "type": "string",
            "description": "Lesson title.",
            "examples": [
              "The Magic of Reading"
            ]
          },
          "snippet": {
            "type": "string",
            "description": "Text around the match, elided with ellipses.",
            "examples": [
              "…Books take you anywhere — discover why reading is the best superpower…"
            ]
          },
          "matchedField": {
            "type": "string",
            "description": "Which field matched.",
            "enum": [
              "title",
              "subtitle",
              "label",
              "bullet",
              "quiz"
            ],
            "examples": [
              "subtitle"
            ]
          },
          "url": {
            "type": "string",
            "description": "Public scolavo.com page for the class.",
            "format": "uri",
            "examples": [
              "https://www.scolavo.com/primary/language-arts?class=1"
            ]
          }
        }
      },
      "SearchPage": {
        "description": "A page of search hits.",
        "allOf": [
          {
            "$ref": "#/components/schemas/Page"
          },
          {
            "type": "object",
            "required": [
              "query"
            ],
            "properties": {
              "items": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/SearchHit"
                }
              },
              "query": {
                "type": "string",
                "description": "The query that was run, echoed back.",
                "examples": [
                  "photosynthesis"
                ]
              }
            }
          }
        ]
      },
      "ExportZipUrl": {
        "type": "object",
        "description": "A bundle delivered as a short-lived signed URL. The default, and the right choice for a pipeline: the bundle bytes never traverse this API, so the gateway response limit does not apply.",
        "required": [
          "format",
          "url",
          "expiresAt",
          "classCount",
          "contentVersion",
          "_license"
        ],
        "properties": {
          "format": {
            "type": "string",
            "const": "zip_url",
            "examples": [
              "zip_url"
            ]
          },
          "url": {
            "type": "string",
            "description": "Signed URL to the stamped zip, valid 15 minutes. The archive holds manifest.json (BundleManifest), one class-{n}.json per class (ExportedLesson), transcripts for narrated classes, media-manifest.json and LICENSE.txt — see ExportBundlePayload.",
            "format": "uri"
          },
          "expiresAt": {
            "type": "string",
            "description": "When the URL stops working.",
            "format": "date-time",
            "examples": [
              "2026-08-20T12:15:00.000Z"
            ]
          },
          "classCount": {
            "type": "integer",
            "description": "Classes in the bundle — and the number of downloads metered.",
            "examples": [
              30
            ]
          },
          "subjectSlug": {
            "$ref": "#/components/schemas/SubjectSlug"
          },
          "tier": {
            "$ref": "#/components/schemas/TierId"
          },
          "lessonId": {
            "$ref": "#/components/schemas/LessonId",
            "description": "Present on a class export only."
          },
          "contentVersion": {
            "type": "string",
            "description": "Corpus build that produced this response, e.g. \"2026-08-04+4fd77ddd\". Also returned as the X-Scolavo-Content-Version header, and an input to every ETag: when it changes, cached ETags are stale and outstanding cursors are expired."
          },
          "_license": {
            "$ref": "#/components/schemas/LicenseEnvelope"
          }
        }
      },
      "ExportInline": {
        "type": "object",
        "description": "The same bundle gzipped, base64-encoded and returned in-band, for pipelines that cannot follow a link out to object storage. Bounded by the gateway response limit — a large subject will not fit and answers 413, at which point zip_url is the answer.",
        "required": [
          "format",
          "encoding",
          "data",
          "classCount",
          "contentVersion",
          "_license"
        ],
        "properties": {
          "format": {
            "type": "string",
            "const": "inline",
            "examples": [
              "inline"
            ]
          },
          "encoding": {
            "type": "string",
            "const": "gzip+base64",
            "examples": [
              "gzip+base64"
            ]
          },
          "data": {
            "type": "string",
            "description": "base64(gzip(JSON.stringify(payload))), where payload is an ExportBundlePayload — the same manifest and stamped classes a zip carries, without the zip container.",
            "contentEncoding": "base64",
            "contentMediaType": "application/gzip",
            "contentSchema": {
              "$ref": "#/components/schemas/ExportBundlePayload"
            }
          },
          "classCount": {
            "type": "integer",
            "description": "Classes in the bundle.",
            "examples": [
              30
            ]
          },
          "subjectSlug": {
            "$ref": "#/components/schemas/SubjectSlug"
          },
          "tier": {
            "$ref": "#/components/schemas/TierId"
          },
          "lessonId": {
            "$ref": "#/components/schemas/LessonId",
            "description": "Present on a class export only."
          },
          "contentVersion": {
            "type": "string",
            "description": "Corpus build that produced this response, e.g. \"2026-08-04+4fd77ddd\". Also returned as the X-Scolavo-Content-Version header, and an input to every ETag: when it changes, cached ETags are stale and outstanding cursors are expired."
          },
          "_license": {
            "$ref": "#/components/schemas/LicenseEnvelope"
          }
        }
      },
      "ExportResult": {
        "description": "An export, in whichever form `format` asked for.",
        "oneOf": [
          {
            "$ref": "#/components/schemas/ExportZipUrl"
          },
          {
            "$ref": "#/components/schemas/ExportInline"
          }
        ],
        "discriminator": {
          "propertyName": "format",
          "mapping": {
            "zip_url": "#/components/schemas/ExportZipUrl",
            "inline": "#/components/schemas/ExportInline"
          }
        }
      },
      "OrgStamp": {
        "type": "object",
        "description": "Who this bundle was minted for, and when. In manifest.json.",
        "required": [
          "orgId",
          "licenseId",
          "exportedAt",
          "tag"
        ],
        "properties": {
          "orgId": {
            "type": "string",
            "description": "Licensed organization.",
            "examples": [
              "org-453b2b2bdf"
            ]
          },
          "licenseId": {
            "type": "string",
            "description": "Licence the export was served under.",
            "examples": [
              "ECLA-1-org-453b2b2bdf"
            ]
          },
          "eclaVersion": {
            "type": "integer",
            "description": "ECLA version in force at export time.",
            "examples": [
              1
            ]
          },
          "exportedAt": {
            "type": "string",
            "description": "When the bundle was built.",
            "format": "date-time",
            "examples": [
              "2026-08-20T12:00:00.000Z"
            ]
          },
          "tag": {
            "type": "string",
            "description": "Forensic tag over (organization, subject).",
            "examples": [
              "7QK3ZB2WMA"
            ]
          }
        }
      },
      "BundleManifest": {
        "type": "object",
        "description": "manifest.json, at the root of every export bundle. `classes[].sha256` is over the exact bytes of that class file as shipped, so integrity can be checked without re-requesting anything.",
        "required": [
          "contentVersion",
          "subjectSlug",
          "tier",
          "lessonCount",
          "classes",
          "org"
        ],
        "properties": {
          "contentVersion": {
            "$ref": "#/components/schemas/ContentVersion"
          },
          "mediaUrlScheme": {
            "type": "integer",
            "description": "Version of the media-manifest URL scheme. 1 today.",
            "examples": [
              1
            ]
          },
          "subjectSlug": {
            "$ref": "#/components/schemas/SubjectSlug"
          },
          "tier": {
            "$ref": "#/components/schemas/TierId"
          },
          "lessonCount": {
            "type": "integer",
            "description": "Classes in the bundle.",
            "examples": [
              30
            ]
          },
          "licenseUrl": {
            "type": "string",
            "description": "The licence terms.",
            "format": "uri",
            "examples": [
              "https://www.scolavo.com/terms"
            ]
          },
          "attribution": {
            "type": "string",
            "description": "Required attribution sentence, also written to LICENSE.txt.",
            "examples": [
              "© Scolavo (www.scolavo.com) — licensed for internal use under the Scolavo Enterprise Content License Agreement (ECLA)."
            ]
          },
          "classes": {
            "type": "array",
            "description": "One entry per class file in the bundle.",
            "items": {
              "type": "object",
              "required": [
                "classNum",
                "title",
                "sha256"
              ],
              "properties": {
                "classNum": {
                  "type": "integer",
                  "description": "Class number; the file is class-{classNum}.json.",
                  "examples": [
                    1
                  ]
                },
                "title": {
                  "type": "string",
                  "description": "Class title."
                },
                "sha256": {
                  "type": "string",
                  "description": "SHA-256 of the class file as shipped.",
                  "pattern": "^[0-9a-f]{64}$"
                }
              }
            }
          },
          "org": {
            "$ref": "#/components/schemas/OrgStamp"
          }
        }
      },
      "ExportedLesson": {
        "type": "object",
        "description": "A class as stamped into a bundle. Field-for-field the same as Lesson (see that schema) with two differences, both licence machinery: `_license` carries the FULL envelope rather than the lean per-response one, and card bodies carry per-organization whitespace canaries — a doubled space after the first sentence of two deterministically chosen cards. The canaries survive stripping the envelope, which is the point of them; they are invisible in rendered text and change no meaning. Do not normalise whitespace if you want a leak to stay attributable to whoever leaked it.",
        "required": [
          "lessonId",
          "_license"
        ],
        "properties": {
          "lessonId": {
            "$ref": "#/components/schemas/LessonId"
          },
          "_license": {
            "$ref": "#/components/schemas/LicenseEnvelopeFull"
          }
        },
        "x-scolavo-same-shape-as": "#/components/schemas/Lesson"
      },
      "ExportBundlePayload": {
        "type": "object",
        "description": "The decoded contents of a bundle: what `format=inline` base64-gzips into `data`, and what a zip holds as manifest.json plus class-{n}.json files. A zip additionally carries class-{n}.transcript.md for narrated classes, media-manifest.json (the ONE place raw asset URLs are licensed to appear), and LICENSE.txt.",
        "required": [
          "manifest",
          "classes"
        ],
        "properties": {
          "manifest": {
            "$ref": "#/components/schemas/BundleManifest"
          },
          "classes": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ExportedLesson"
            },
            "description": "Every class in the bundle, in order."
          }
        }
      },
      "TestprepSection": {
        "type": "object",
        "description": "A section within a test.",
        "required": [
          "id",
          "name",
          "items"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "Section id, unique within its test. The only valid values for the `section` filter on this test.",
            "examples": [
              "rw"
            ]
          },
          "name": {
            "type": "string",
            "description": "Display name.",
            "examples": [
              "Reading & Writing"
            ]
          },
          "items": {
            "type": "integer",
            "description": "Practice items available in this section.",
            "examples": [
              243
            ]
          }
        },
        "examples": [
          {
            "id": "rw",
            "name": "Reading & Writing",
            "items": 243
          }
        ]
      },
      "TestprepTest": {
        "type": "object",
        "description": "One practice test.",
        "required": [
          "test",
          "name",
          "sections",
          "items"
        ],
        "properties": {
          "test": {
            "type": "string",
            "description": "Test id.",
            "enum": [
              "digital-sat",
              "act",
              "psat-nmsqt",
              "caaspp"
            ],
            "examples": [
              "digital-sat"
            ]
          },
          "name": {
            "type": "string",
            "description": "Display name.",
            "examples": [
              "Digital SAT"
            ]
          },
          "sections": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/TestprepSection"
            }
          },
          "items": {
            "type": "integer",
            "description": "Total practice items across all sections — size a walk with this before you start paying per item.",
            "examples": [
              440
            ]
          }
        },
        "examples": [
          {
            "test": "digital-sat",
            "name": "Digital SAT",
            "sections": [
              {
                "id": "rw",
                "name": "Reading & Writing",
                "items": 243
              },
              {
                "id": "math",
                "name": "Math",
                "items": 197
              }
            ],
            "items": 440
          }
        ]
      },
      "TestprepCatalogPage": {
        "description": "The practice tests.",
        "allOf": [
          {
            "$ref": "#/components/schemas/Page"
          },
          {
            "type": "object",
            "properties": {
              "items": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/TestprepTest"
                }
              },
              "note": {
                "type": "string",
                "description": "Provenance disclaimer — a licensing requirement, not decoration.",
                "examples": [
                  "All items are original Scolavo-authored practice items — not actual exam questions."
                ]
              }
            }
          }
        ]
      },
      "TestprepItem": {
        "type": "object",
        "description": "One original Scolavo-authored practice item. Not an actual exam question.",
        "required": [
          "id",
          "section",
          "prompt",
          "answer"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "Item id, unique within its test.",
            "examples": [
              "rw-m1-1"
            ]
          },
          "section": {
            "type": "string",
            "description": "Section this item belongs to.",
            "examples": [
              "rw"
            ]
          },
          "module": {
            "type": "string",
            "description": "Module within the section, where the test is modular. The Digital SAT is adaptive, so module2easy and module2hard are the two routings of the same slot.",
            "examples": [
              "module1"
            ]
          },
          "domain": {
            "type": "string",
            "description": "Content domain.",
            "examples": [
              "Craft & Structure"
            ]
          },
          "skill": {
            "type": "string",
            "description": "Finer-grained skill tag, where authored."
          },
          "difficulty": {
            "type": "string",
            "description": "E (easy), M (medium) or H (hard).",
            "enum": [
              "E",
              "M",
              "H"
            ],
            "examples": [
              "E"
            ]
          },
          "type": {
            "type": "string",
            "description": "Item format.",
            "enum": [
              "mcq",
              "numeric"
            ],
            "examples": [
              "mcq"
            ]
          },
          "passage": {
            "type": "string",
            "description": "Stimulus text the prompt refers to, where the item has one.",
            "examples": [
              "When the museum acquired the painter's early sketchbooks, curators were surprised by how prolific she had been…"
            ]
          },
          "prompt": {
            "type": "string",
            "description": "The question.",
            "examples": [
              "As used in the text, what does the word \"prolific\" most nearly mean?"
            ]
          },
          "options": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Absent on numeric items, which are free-entry.",
            "examples": [
              [
                "Productive",
                "Careless",
                "Famous",
                "Secretive"
              ]
            ]
          },
          "answer": {
            "type": [
              "number",
              "string"
            ],
            "description": "For `type: \"mcq\"`, the zero-based index of the correct option. For `type: \"numeric\"`, the expected value — a number, which may be fractional (0.375), or a string where the bank authored it that way. Branch on `type`, not on the JSON type of this field.",
            "examples": [
              0
            ]
          },
          "rationale": {
            "type": "string",
            "description": "Why the correct answer is correct.",
            "examples": [
              "The clause explaining the surprise — more than four hundred drawings at a young age — signals that \"prolific\" means producing a great deal of work."
            ]
          },
          "optionRationales": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Per-option explanations, parallel to `options`, where authored."
          }
        }
      },
      "TestprepItemPage": {
        "description": "A page of practice items. May be SHORTER than `limit` when your remaining monthly item allowance is smaller than the page you asked for — compare what you received against `total`, not against `limit`.",
        "allOf": [
          {
            "$ref": "#/components/schemas/Page"
          },
          {
            "type": "object",
            "required": [
              "test"
            ],
            "properties": {
              "items": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/TestprepItem"
                }
              },
              "test": {
                "type": "string",
                "description": "Test these items belong to.",
                "examples": [
                  "digital-sat"
                ]
              },
              "section": {
                "type": "string",
                "description": "Section filter that was applied, when one was.",
                "examples": [
                  "rw"
                ]
              }
            }
          }
        ]
      },
      "QuotaCounter": {
        "type": "object",
        "description": "One monthly meter. A null limit means the meter is telemetry only on this plan — counted and visible, but it will not refuse a call.",
        "required": [
          "used",
          "limit"
        ],
        "properties": {
          "used": {
            "type": "integer",
            "description": "Consumed this month.",
            "examples": [
              1284
            ]
          },
          "limit": {
            "type": [
              "integer",
              "null"
            ],
            "description": "The cap, or null when uncapped on this plan."
          }
        },
        "examples": [
          {
            "used": 1284,
            "limit": 5000
          }
        ]
      },
      "LicenseStatus": {
        "type": "object",
        "description": "Your organization's licence and current usage. Every meter a call can be charged against appears in `quota` — if you are being metered on something, you can see it here. This is the one content-bearing response with no `_license` envelope: it describes the licence rather than being served under it.",
        "required": [
          "orgId",
          "status",
          "contentGrants",
          "quota",
          "contentVersion"
        ],
        "properties": {
          "orgId": {
            "type": "string",
            "description": "Your organization id.",
            "examples": [
              "org-453b2b2bdf"
            ]
          },
          "orgName": {
            "type": "string",
            "description": "Organization name.",
            "examples": [
              "Vizlings, Inc."
            ]
          },
          "tier": {
            "type": "string",
            "description": "Commercial plan tier — NOT a curriculum tier.",
            "examples": [
              "pilot"
            ]
          },
          "status": {
            "type": "string",
            "description": "Plan status. Anything other than \"active\" means content routes answer 403 ORG_SUSPENDED until it is resolved.",
            "examples": [
              "active"
            ]
          },
          "contentGrants": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "What you are licensed for, in the grammar \"{tier}\" (a whole curriculum tier) or \"{tier}/{subjectSlug}\" (one subject). This is the authoritative answer to \"why did that 403\": a request whose tier appears in no grant is TIER_NOT_LICENSED; one whose tier appears but whose subject does not is CONTENT_NOT_GRANTED.",
            "examples": [
              [
                "college",
                "topics/ai-and-llms",
                "high/hs-mathematics-algebra"
              ]
            ]
          },
          "quota": {
            "type": "object",
            "description": "This month's usage against each monthly meter.",
            "properties": {
              "calls": {
                "$ref": "#/components/schemas/QuotaCounter"
              },
              "downloads": {
                "$ref": "#/components/schemas/QuotaCounter"
              },
              "exports": {
                "$ref": "#/components/schemas/QuotaCounter"
              },
              "testprepitems": {
                "$ref": "#/components/schemas/QuotaCounter"
              }
            }
          },
          "rateLimits": {
            "type": "object",
            "description": "Hourly velocity caps. Separate from the monthly quotas, and they produce 429 RATE_LIMITED rather than QUOTA_EXCEEDED; null means uncapped on this plan. Size a bulk job against these, not only against the monthly numbers.",
            "properties": {
              "callsPerHour": {
                "type": [
                  "integer",
                  "null"
                ],
                "description": "Requests per hour."
              },
              "lessonReadsPerHour": {
                "type": [
                  "integer",
                  "null"
                ],
                "description": "Lesson-shaped reads per hour — the four lesson routes and the test-prep items route."
              },
              "urlMintsPerHour": {
                "type": [
                  "integer",
                  "null"
                ],
                "description": "Signed URLs per hour. A batch of 100 consumes 100 units."
              }
            },
            "examples": [
              {
                "callsPerHour": null,
                "lessonReadsPerHour": 200,
                "urlMintsPerHour": 50
              }
            ]
          },
          "resetsAt": {
            "type": "string",
            "description": "When the monthly counters reset (UTC month boundary).",
            "format": "date-time",
            "examples": [
              "2026-09-01T00:00:00.000Z"
            ]
          },
          "eclaVersion": {
            "type": "integer",
            "description": "ECLA version your organization has accepted.",
            "examples": [
              1
            ]
          },
          "requiredEclaVersion": {
            "type": "integer",
            "description": "ECLA version currently required. If this exceeds eclaVersion, content routes answer 403 LICENSE_REQUIRED until the new terms are accepted.",
            "examples": [
              1
            ]
          },
          "renewsAt": {
            "type": "string",
            "description": "Licence renewal date. Once past, content routes deny on the very next call — there is no grace period.",
            "format": "date-time",
            "examples": [
              "2027-01-31T00:00:00.000Z"
            ]
          },
          "licenseUrl": {
            "type": "string",
            "description": "The licence terms.",
            "format": "uri",
            "examples": [
              "https://www.scolavo.com/terms"
            ]
          },
          "contentVersion": {
            "$ref": "#/components/schemas/ContentVersion"
          }
        }
      },
      "LessonProgress": {
        "type": "object",
        "description": "One class the learner has a record for. Classes with no record are absent entirely — absence means \"not started\".",
        "required": [
          "subjectSlug",
          "classNum",
          "level"
        ],
        "properties": {
          "lessonId": {
            "$ref": "#/components/schemas/LessonId",
            "description": "Null when the stored record names a subject no longer in the catalog."
          },
          "subjectSlug": {
            "$ref": "#/components/schemas/SubjectSlug"
          },
          "classNum": {
            "type": "integer",
            "description": "1-based class number.",
            "minimum": 1,
            "examples": [
              4
            ]
          },
          "pct": {
            "type": [
              "number",
              "null"
            ],
            "description": "Recorded quiz score 0-100, or null when the class was marked complete without one.",
            "examples": [
              92
            ]
          },
          "level": {
            "type": "string",
            "description": "Mastery level. \"Mastered\" requires a recorded score of 80 or more; any other completion is \"Completed\". \"Not started\" and \"Started\" exist for parity with scolavo.com but never appear here, because a record exists only once a class is finished.",
            "enum": [
              "Not started",
              "Started",
              "Completed",
              "Mastered"
            ],
            "examples": [
              "Mastered"
            ]
          },
          "at": {
            "type": [
              "integer",
              "null"
            ],
            "description": "When it was recorded, epoch milliseconds."
          }
        },
        "examples": [
          {
            "lessonId": "high/hs-mathematics-algebra/4",
            "subjectSlug": "hs-mathematics-algebra",
            "classNum": 4,
            "pct": 92,
            "level": "Mastered",
            "at": 1755691200000
          }
        ]
      },
      "ProgressSummary": {
        "description": "One learner's progress — the same record scolavo.com shows them. Recorded classes are in `items`, most recent first.",
        "allOf": [
          {
            "$ref": "#/components/schemas/Page"
          },
          {
            "type": "object",
            "required": [
              "learnerId",
              "xp",
              "streak",
              "completedLessons",
              "masteredLessons"
            ],
            "properties": {
              "items": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/LessonProgress"
                }
              },
              "learnerId": {
                "type": "string",
                "description": "Learner profile.",
                "examples": [
                  "l1"
                ]
              },
              "xp": {
                "type": "integer",
                "description": "Experience points. Owned by scolavo.com; this API never changes it.",
                "examples": [
                  3400
                ]
              },
              "streak": {
                "type": "integer",
                "description": "Current day streak. Owned by scolavo.com.",
                "examples": [
                  7
                ]
              },
              "completedLessons": {
                "type": "integer",
                "description": "Classes with a record.",
                "examples": [
                  42
                ]
              },
              "masteredLessons": {
                "type": "integer",
                "description": "Classes recorded at 80 or above.",
                "examples": [
                  31
                ]
              },
              "lastActive": {
                "type": [
                  "integer",
                  "null"
                ],
                "description": "Most recent record, epoch milliseconds. Null when the learner has no records."
              }
            }
          }
        ]
      },
      "ProgressWrite": {
        "type": "object",
        "description": "The body of a progress write. Both fields are optional: an empty body records a plain completion for the default learner.",
        "properties": {
          "score": {
            "type": "number",
            "minimum": 0,
            "maximum": 100,
            "description": "Quiz score 0-100. 80 or above marks the class Mastered; anything else, or omitting it, marks it Completed.",
            "examples": [
              92
            ]
          },
          "learnerId": {
            "type": "string",
            "description": "Learner profile under this account (default \"l1\").",
            "pattern": "^[A-Za-z0-9_-]{1,40}$",
            "examples": [
              "l1"
            ]
          }
        },
        "examples": [
          {
            "score": 92
          }
        ]
      },
      "ProgressWriteResult": {
        "type": "object",
        "description": "What was written.",
        "required": [
          "lessonId",
          "learnerId",
          "recorded",
          "contentVersion",
          "_license"
        ],
        "properties": {
          "lessonId": {
            "$ref": "#/components/schemas/LessonId"
          },
          "learnerId": {
            "type": "string",
            "description": "Learner the record belongs to.",
            "examples": [
              "l1"
            ]
          },
          "recorded": {
            "type": "object",
            "description": "The stored record, exactly as the site would read it back.",
            "required": [
              "pct",
              "level",
              "at"
            ],
            "properties": {
              "pct": {
                "type": [
                  "number",
                  "null"
                ],
                "description": "The score as stored, or null when none was supplied.",
                "examples": [
                  92
                ]
              },
              "level": {
                "type": "string",
                "description": "Resulting level.",
                "enum": [
                  "Completed",
                  "Mastered"
                ],
                "examples": [
                  "Mastered"
                ]
              },
              "at": {
                "type": "integer",
                "description": "Write time, epoch milliseconds.",
                "examples": [
                  1755691200000
                ]
              }
            },
            "examples": [
              {
                "pct": 92,
                "level": "Mastered",
                "at": 1755691200000
              }
            ]
          },
          "contentVersion": {
            "type": "string",
            "description": "Corpus build that produced this response, e.g. \"2026-08-04+4fd77ddd\". Also returned as the X-Scolavo-Content-Version header, and an input to every ETag: when it changes, cached ETags are stale and outstanding cursors are expired."
          },
          "_license": {
            "$ref": "#/components/schemas/LicenseEnvelope"
          }
        }
      },
      "Error": {
        "type": "object",
        "description": "The contents of a failed request's `error` object. `code` is the stable, machine-readable reason and\nis the ONLY field to branch on — `message` is written for a human reading a log and may be reworded at\nany time.\n\nThe codes are shared verbatim with the Scolavo MCP server, so an integration that already handles them\nneeds no new vocabulary. Extra diagnostic fields ride alongside, chosen to make the error actionable\nrather than merely accurate: a 404 for an out-of-range class reports the subject's real class range, a\n403 for an ungranted subject names the grant that would have covered it, and a 429 says when to come\nback. Which extras appear depends on the code.",
        "required": [
          "code",
          "message"
        ],
        "properties": {
          "code": {
            "type": "string",
            "enum": [
              "INVALID_PARAMETER",
              "BAD_REQUEST",
              "BAD_LESSON_ID",
              "BAD_MEDIA_ID",
              "CURSOR_EXPIRED",
              "BATCH_TOO_LARGE",
              "AMBIGUOUS_SUBJECT",
              "invalid_token",
              "invalid_request",
              "insufficient_scope",
              "NO_ORGANIZATION",
              "ORG_SUSPENDED",
              "LICENSE_EXPIRED",
              "LICENSE_REQUIRED",
              "TIER_NOT_LICENSED",
              "CONTENT_NOT_GRANTED",
              "LEARNER_REQUIRED",
              "UNKNOWN_SUBJECT",
              "LESSON_NOT_FOUND",
              "UNKNOWN_MEDIA",
              "UNKNOWN_TEST",
              "UNKNOWN_SECTION",
              "NOT_FOUND",
              "METHOD_NOT_ALLOWED",
              "WRITE_CONFLICT",
              "PAYLOAD_TOO_LARGE",
              "RATE_LIMITED",
              "QUOTA_EXCEEDED",
              "EXPORT_LIMIT",
              "INTERNAL"
            ],
            "description": "The branch table — every code this API can return:\n- `INVALID_PARAMETER` (400) — A parameter is missing, malformed, or not one of the legal values. The body names the parameter and, for an enum, what was allowed.\n- `BAD_REQUEST` (400) — Generic malformed request. Accepted synonym of INVALID_PARAMETER.\n- `BAD_LESSON_ID` (400) — The path did not resolve to a \"{tier}/{subjectSlug}/{classNum}\" lesson id.\n- `BAD_MEDIA_ID` (400) — Reserved. A mediaId is \"{lessonId}#{cardId}#{type}\"; in practice a malformed one fails on the lessonId in front of the # and comes back as BAD_LESSON_ID. Take ids from the media route, never build them.\n- `CURSOR_EXPIRED` (400) — The cursor was minted against an older contentVersion. Restart the walk from page 1.\n- `BATCH_TOO_LARGE` (400) — More than 100 mediaIds in one POST /v1/media/urls. Split the batch.\n- `AMBIGUOUS_SUBJECT` (400) — The slug exists in more than one tier. Disambiguate with the `tier` query parameter.\n- `invalid_token` (401) — Missing, malformed, expired, or unverifiable bearer token.\n- `invalid_request` (401) — Reserved. This is the OAuth 2.0 code for a malformed request, but this door does not emit it: a missing, malformed or non-Bearer Authorization header all fail in verify() and come back as invalid_token. Branch on invalid_token.\n- `insufficient_scope` (403) — The token lacks the scope this route requires. The body and the challenge both name it.\n- `NO_ORGANIZATION` (403) — The token is not bound to a licensed organization.\n- `ORG_SUSPENDED` (403) — The plan is not active — includes an enumeration-alarm auto-suspend.\n- `LICENSE_EXPIRED` (403) — The plan's renewsAt is in the past. Denied on the very next call after it lapses; there is no grace period.\n- `LICENSE_REQUIRED` (403) — A newer ECLA version must be accepted. See requiredEclaVersion on GET /v1/license.\n- `TIER_NOT_LICENSED` (403) — No grant covers this content tier.\n- `CONTENT_NOT_GRANTED` (403) — The tier is licensed but this subject is not (per-subject licences).\n- `LEARNER_REQUIRED` (403) — The progress routes need an interactive learner token; a client_credentials token carries no learner.\n- `UNKNOWN_SUBJECT` (404) — No such subjectSlug. Slugs are tier-specific and are matched EXACTLY — display names and partial slugs are not accepted.\n- `LESSON_NOT_FOUND` (404) — classNum out of range for the subject, or the lesson object is absent. The body reports the real range.\n- `UNKNOWN_MEDIA` (404) — No such mediaId on that lesson. In a batch mint this fails the WHOLE batch.\n- `UNKNOWN_TEST` (404) — No such test id.\n- `UNKNOWN_SECTION` (404) — That test has no such section; the body lists the ones it does have.\n- `NOT_FOUND` (404) — No route matches this method and path.\n- `METHOD_NOT_ALLOWED` (405) — The path exists but not for this method. The Allow header lists the methods it does serve.\n- `WRITE_CONFLICT` (409) — Another writer updated the learner record; nothing was saved. Retry after a short pause.\n- `PAYLOAD_TOO_LARGE` (413) — The response would exceed the API Gateway response limit. Lower `limit`, use detail=brief, or pull the subject through /v1/exports.\n- `RATE_LIMITED` (429) — An hourly velocity cap (calls / lesson reads / URL mints). Retry-After holds the remainder of the hour.\n- `QUOTA_EXCEEDED` (429) — A monthly quota is exhausted. Retry-After points at the monthly reset — treat a large value as \"stop\", not \"spin\".\n- `EXPORT_LIMIT` (429) — The monthly export allowance is exhausted; the body carries exportsUsed / exportsLimit.\n- `INTERNAL` (500) — Server fault. Safe to retry once; then contact licensing@scolavo.com quoting the requestId.",
            "examples": [
              "CONTENT_NOT_GRANTED"
            ]
          },
          "message": {
            "type": "string",
            "description": "Human-readable explanation. Do not parse.",
            "examples": [
              "class 44 is out of range — \"hs-mathematics-algebra\" has classes 1–30"
            ]
          },
          "hint": {
            "type": "string",
            "description": "What to do next, where there is a concrete next step.",
            "examples": [
              "GET /v1/subjects/hs-mathematics-algebra/classes for the valid classNum values."
            ]
          },
          "requestId": {
            "type": "string",
            "description": "Correlation id, matching the X-Request-Id header. Quote it to support."
          },
          "parameter": {
            "type": "string",
            "description": "On a parameter error: which parameter was rejected.",
            "examples": [
              "detail"
            ]
          },
          "received": {
            "type": "string",
            "description": "On a parameter error: the offending value, truncated.",
            "examples": [
              "verbose"
            ]
          },
          "allowed": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "On an enum parameter error: the legal values.",
            "examples": [
              [
                "brief",
                "standard",
                "full"
              ]
            ]
          },
          "scope": {
            "type": "string",
            "description": "On insufficient_scope: the scope this route requires.",
            "examples": [
              "scolavo-mcp/media.download"
            ]
          },
          "allow": {
            "type": "string",
            "description": "On METHOD_NOT_ALLOWED: the methods this path does serve.",
            "examples": [
              "GET"
            ]
          },
          "retryAfterSeconds": {
            "type": "integer",
            "description": "On 429 and 409: seconds until a retry can succeed. Mirrors the Retry-After header.",
            "examples": [
              1847
            ]
          },
          "quotaResetsAt": {
            "type": "string",
            "description": "On QUOTA_EXCEEDED: when the monthly counters reset.",
            "format": "date-time"
          },
          "requiredTier": {
            "type": "string",
            "description": "On TIER_NOT_LICENSED: the content tier your licence would need.",
            "examples": [
              "college"
            ]
          },
          "grant": {
            "type": "string",
            "description": "On CONTENT_NOT_GRANTED: the exact grant that would have covered this request.",
            "examples": [
              "topics/ai-and-llms"
            ]
          },
          "candidates": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "On UNKNOWN_SUBJECT: the closest real subjectSlugs, best first, so a caller can retry without enumerating the catalogue.",
            "examples": [
              [
                "hs-science-biology",
                "hs-science-chemistry"
              ]
            ]
          },
          "didYouMean": {
            "type": "string",
            "description": "On UNKNOWN_SUBJECT: the single best-matching subjectSlug, when one stands out. The `hint` already contains a ready-made retry using it.",
            "examples": [
              "hs-science-biology"
            ]
          },
          "tiers": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "On AMBIGUOUS_SUBJECT: the tiers this slug exists in. Re-send with ?tier= one of them.",
            "examples": [
              [
                "middle",
                "high"
              ]
            ]
          },
          "lessonId": {
            "type": "string",
            "description": "Echoed on lesson errors: the lessonId that failed to resolve.",
            "examples": [
              "high/hs-science-biology/7"
            ]
          },
          "tier": {
            "type": "string",
            "description": "Echoed on subject and lesson errors: the tier that was searched.",
            "examples": [
              "high"
            ]
          },
          "subjectSlug": {
            "type": "string",
            "description": "Echoed on subject and lesson errors: the slug that was looked up.",
            "examples": [
              "hs-science-biology"
            ]
          },
          "classNum": {
            "type": "integer",
            "description": "Echoed on LESSON_NOT_FOUND: the class number that was out of range.",
            "examples": [
              99
            ]
          },
          "maxBatch": {
            "type": "integer",
            "description": "On BATCH_TOO_LARGE: the largest batch this route accepts.",
            "examples": [
              100
            ]
          },
          "test": {
            "type": "string",
            "description": "Echoed on UNKNOWN_TEST and UNKNOWN_SECTION: the test id that was requested.",
            "examples": [
              "digital-sat"
            ]
          },
          "sections": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "On UNKNOWN_SECTION: the section ids this test does have.",
            "examples": [
              [
                "rw",
                "math"
              ]
            ]
          },
          "classCount": {
            "type": "integer",
            "description": "On an out-of-range LESSON_NOT_FOUND: how many classes the subject actually has.",
            "examples": [
              30
            ]
          },
          "bytes": {
            "type": "integer",
            "description": "On PAYLOAD_TOO_LARGE: how large the response would have been."
          },
          "exportsUsed": {
            "type": "integer",
            "description": "On EXPORT_LIMIT: exports consumed this month."
          },
          "exportsLimit": {
            "type": "integer",
            "description": "On EXPORT_LIMIT: the monthly export allowance."
          },
          "requiredEclaVersion": {
            "type": "integer",
            "description": "On LICENSE_REQUIRED: the ECLA version that must be accepted."
          },
          "eclaVersion": {
            "type": "integer",
            "description": "On LICENSE_REQUIRED: the version currently accepted."
          },
          "renewsAt": {
            "type": "string",
            "description": "On LICENSE_EXPIRED: the renewal date that lapsed.",
            "format": "date-time"
          },
          "status": {
            "type": "string",
            "description": "On ORG_SUSPENDED: the plan status that blocked the call.",
            "examples": [
              "suspended"
            ]
          }
        },
        "examples": [
          {
            "code": "CONTENT_NOT_GRANTED",
            "message": "your licence does not cover \"topics/ai-and-llms\"",
            "grant": "topics/ai-and-llms",
            "hint": "GET /v1/license to see your content grants, or contact licensing@scolavo.com.",
            "requestId": "b3f1c2a4-7e2d-4a10-9c31-0a7f5d2e8b44"
          },
          {
            "code": "RATE_LIMITED",
            "message": "hourly lesson-read cap reached",
            "retryAfterSeconds": 1847,
            "requestId": "b3f1c2a4-7e2d-4a10-9c31-0a7f5d2e8b44"
          }
        ]
      },
      "ErrorResponse": {
        "type": "object",
        "description": "Every error body in this API. The detail is NESTED under `error` — a successful response never carries this key, so `if (body.error)` is a complete failure check.",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "$ref": "#/components/schemas/Error"
          }
        },
        "examples": [
          {
            "error": {
              "code": "LESSON_NOT_FOUND",
              "message": "class 44 is out of range — \"hs-mathematics-algebra\" has classes 1–30",
              "classCount": 30,
              "hint": "GET /v1/subjects/hs-mathematics-algebra/classes for the valid classNum values.",
              "requestId": "b3f1c2a4-7e2d-4a10-9c31-0a7f5d2e8b44"
            }
          }
        ]
      }
    }
  }
}