API Contracts
:::info Source
Sourced from services/assessment-service/API_CONTRACTS.md in the documentation repo.
:::
Surface: Tenant REST (authenticated) + internal S2S endpoints Versioning:
/api/v1/…per doc 05 §2 Envelopes, idempotency, errors: conform to doc 05
This document is the service-level OpenAPI source of truth. The generated openapi.v1.yaml is derived from this spec and published to the platform API catalogue.
1. Surface summary
| Path | Method | Purpose | Auth | Idempotent | AuthZ |
|---|---|---|---|---|---|
/api/v1/quiz-banks | POST | Create a new quiz bank (author) | JWT | Required (header) | quiz_bank:create |
/api/v1/quiz-banks/{id} | GET | Read a quiz bank (author view — includes answers) | JWT | Yes | quiz_bank:read |
/api/v1/quiz-banks/{id} | PATCH | Update metadata or grading rule | JWT | Required | quiz_bank:update |
/api/v1/quiz-banks/{id}/publish | POST | Publish (state: draft → published) | JWT | Required | quiz_bank:publish |
/api/v1/quiz-banks/{id}/archive | POST | Archive (state: * → archived) | JWT | Required | quiz_bank:archive |
/api/v1/quiz-banks/{id}/questions | GET | Serve presentation form (answer-stripped, randomized) for a learner attempt | JWT | Yes | quiz_bank:serve (internal scope) |
/api/v1/quiz-banks/{id}/questions | POST | Add a question | JWT | Required | quiz_bank:update |
/api/v1/questions/{id} | PATCH | Update a question | JWT | Required | quiz_bank:update |
/api/v1/questions/{id}/deactivate | POST | Soft-delete question | JWT | Required | quiz_bank:update |
/api/v1/quiz-banks/{id}/ai/generate | POST | Draft questions with AI (HITL required) | JWT | Required | quiz_bank:ai_generate |
/api/v1/attempts/{id}/submit-response | POST | Submit a single response (buffered) | JWT | Required | attempt:submit_response (self) |
/api/v1/attempts/{id}/score | POST | Close attempt and compute score | JWT | Required | attempt:score (self or system) |
/api/v1/attempts/{id} | GET | Read attempt result (learner sees self; admin sees all in tenant) | JWT | Yes | attempt:read |
/api/v1/attempts/{id}/regrade | POST | Trigger regrade (admin) | JWT | Required | attempt:regrade |
/api/v1/attempts/{id}/responses/{qid}/human-grade | POST | Human-override AI grade | JWT | Required | attempt:human_grade |
/api/v1/branching-scenarios | POST | Create scenario | JWT | Required | scenario:create |
/api/v1/branching-scenarios/{id} | GET | Read scenario (author view) | JWT | Yes | scenario:read |
/api/v1/branching-scenarios/{id} | PATCH | Update metadata/scoring | JWT | Required | scenario:update |
/api/v1/branching-scenarios/{id}/nodes | POST | Add node | JWT | Required | scenario:update |
/api/v1/branching-scenarios/{id}/nodes/{nid} | PATCH | Update node | JWT | Required | scenario:update |
/api/v1/branching-scenarios/{id}/publish | POST | Publish scenario (runs DAG + reachability checks) | JWT | Required | scenario:publish |
/api/v1/branching-scenarios/{id}/navigate | POST | Navigate a choice (stateless server evaluation) | JWT | Yes (pure) | scenario:navigate (self) |
/api/v1/branching-scenarios/{id}/ai/generate | POST | Draft scenario with AI | JWT | Required | scenario:ai_generate |
All request/response bodies use the standard envelope per doc 05 §4–5.
2. Write-endpoint headers (mandatory)
Authorization: Bearer <jwt>
X-Tenant-Id: <tenantId>
Idempotency-Key: <ulid>
Content-Type: application/json; charset=utf-8
Accept-Language: <bcp47>
traceparent: <w3c>
If-Match: "<version>" # required on PATCH, PUT
Missing Idempotency-Key on a write → 400 with problem+json code idempotency.key_required.
3. Representative contracts
3.1 POST /api/v1/quiz-banks
Request body:
{
"title": { "en-US": "Onboarding Quiz", "ar-SA": "اختبار التأهيل" },
"description": { "en-US": "…" },
"gradingRule": {
"passThreshold": 0.8,
"partialCreditDefault": "proportional",
"showCorrectAnswers": "after_attempt",
"maxAttempts": 3
},
"poolConfig": {
"strategy": "sample",
"sampleSize": 10,
"seedStrategy": "userIdAndAttemptId",
"shuffleOptions": true
},
"timeLimit": 1200
}
201 Response:
{
"data": {
"id": "qbk_01HN2K8Z3M…",
"tenantId": "tnt_01HME…",
"state": "draft",
"version": 1,
"createdAt": "2025-04-15T12:00:00Z",
"createdBy": "usr_01HP…"
},
"meta": { "requestId": "req_…", "apiVersion": "v1.0" }
}
Error codes:
422 quiz_bank.invariant_violation.pass_threshold— value outside[0,1]409 idempotency.replay_mismatch403 policy.forbidden
3.2 GET /api/v1/quiz-banks/{id}/questions?attemptId=…&locale=en-US
Purpose: Serve the presentation form to the player.
Response (200):
{
"data": {
"quizBankId": "qbk_01HN…",
"version": 7,
"presentedQuestions": [
{
"id": "qst_01HN…",
"kind": "mcq",
"prompt": "What is the capital of France?",
"options": [
{ "id": "opt_a", "text": "London" },
{ "id": "opt_b", "text": "Paris" },
{ "id": "opt_c", "text": "Berlin" }
],
"weight": 1,
"media": []
}
],
"seed": "seed_01HN…",
"servedAt": "2025-04-15T12:05:00Z",
"timeLimit": 1200
},
"meta": { "requestId": "req_…", "apiVersion": "v1.0" }
}
Contract guarantee (enforced by content-shape linter in CI):
- Response body does not contain keys:
isCorrect,correctIndex,acceptedAnswers,expected,correctBucketId,correct,pairswith right side fully visible before shuffle,rubric,aiProvenance,explanation. - Options are pre-shuffled according to seed; client cannot reconstruct original order.
Caching: ETag: "<bankVersion>-<seedHash>"; cache-private.
3.3 POST /api/v1/attempts/{attemptId}/submit-response
Purpose: Buffered write; store individual response server-side before final scoring (prevents data loss on crash).
Request:
{
"questionId": "qst_01HN…",
"kind": "mcq",
"selectedOptionId": "opt_b",
"durationMs": 3400,
"submittedAt": "2025-04-15T12:06:03.400Z"
}
Response (202):
{
"data": { "attemptId": "att_01HN…", "questionId": "qst_01HN…", "buffered": true },
"meta": { … }
}
Semantics:
- No scoring at this stage.
- Server persists into
response_buffertable keyed by(attemptId, questionId); last-write-wins per question. - Idempotent on
(Idempotency-Key); same key returns200with prior outcome.
3.4 POST /api/v1/attempts/{attemptId}/score
Request:
{
"quizBankId": "qbk_01HN…",
"quizBankVersion": 7,
"responses": [
{ "questionId": "qst_01HN…", "kind": "mcq", "selectedOptionId": "opt_b", "durationMs": 3400 },
{ "questionId": "qst_01HO…", "kind": "short_answer", "text": "Because the mitochondria…", "durationMs": 42100 }
],
"startedAt": "2025-04-15T12:05:10Z",
"submittedAt": "2025-04-15T12:08:50Z",
"offline": {
"scoredOnDevice": true,
"clientScaledScore": 0.75,
"deviceSignature": "eyJhbGciOiJFZERTQSIsImtpZCI6ImRldi1ya…"
},
"integrityFlags": ["tab_switched"]
}
Response (200 deterministic path):
{
"data": {
"attemptId": "att_01HN…",
"rawScore": 8,
"maxScore": 10,
"scaledScore": 0.8,
"passed": true,
"state": "final",
"durationSeconds": 220,
"scoringMode": "deterministic",
"offlineScored": true,
"scoreReconciliation": {
"clientScaledScore": 0.75,
"serverScaledScore": 0.8,
"diffAbs": 0.05,
"mismatch": true,
"resolution": "server_wins"
}
},
"meta": { … }
}
Response (202 AI-grading pending):
{
"data": {
"attemptId": "att_01HN…",
"state": "pending_human_review",
"scoringMode": "mixed",
"pendingResponses": ["qst_01HO…"],
"estimatedReadyAt": "2025-04-15T12:09:20Z"
},
"meta": { … }
}
Error codes:
409 attempt.already_scored422 attempt.expired— ifsubmittedAt − startedAt > timeLimit + grace422 bank.version_mismatch— ifquizBankVersionno longer exists403 policy.forbidden
3.5 POST /api/v1/quiz-banks/{id}/ai/generate
Request:
{
"lessonId": "lsn_01HN…",
"questionCount": 10,
"types": ["mcq", "true_false", "short_answer"],
"difficulty": "medium",
"locale": "en-US"
}
Response (200):
{
"data": {
"draftId": "drft_01HN…",
"questions": [ /* question entities with aiProvenance stamped */ ],
"aiProvenance": {
"model": "gemini-2.5-flash",
"promptId": "quiz_generation",
"promptVersion": "3.1.0",
"traceId": "00-…-…-01",
"generatedAt": "2025-04-15T12:10:00Z",
"cost": { "microUSD": 4300, "tokens": { "in": 2100, "out": 1800 } }
}
},
"meta": { … }
}
- Response is ephemeral — not persisted as
QuizBankstate until author accepts and postsPATCH /quiz-banks/{id}(HITL). - Error
402 ai.budget_exceededif tenant AI budget exhausted. - Error
422 ai.output_invalidif gateway returned schema-invalid content after retries.
3.6 POST /api/v1/branching-scenarios/{id}/navigate
Request:
{
"currentNodeId": "node_05",
"choiceId": "choice_b"
}
Response (200):
{
"data": {
"nextNodeId": "node_08",
"isTerminal": false,
"feedback": { "en-US": "Good choice — let's see the consequences." },
"outcomeWeightSoFar": 3
},
"meta": { … }
}
Terminal response includes terminalOutcome block and learner can POST /score with the traversed path.
3.7 POST /api/v1/attempts/{id}/responses/{qid}/human-grade
Used by reviewers / compliance officers to override AI grades.
Request:
{
"pointsEarned": 6,
"rubricBreakdown": { "clarity": 2, "accuracy": 3, "evidence": 1 },
"rationale": "Response partially addresses mitochondrial structure…",
"overrideAI": true
}
Response (200): updated AttemptResult summary; if this was the last pending response, attempt transitions state: final and emits attempt_result.scored.v1.
4. Public (unauthenticated) endpoints
None. The assessment-service exposes zero public endpoints. Verification-style public access is certification-service's concern.
5. Pagination
All list endpoints (GET /quiz-banks, GET /branching-scenarios) use cursor pagination per doc 05 §9:
GET /api/v1/quiz-banks?pageSize=50&cursor=eyJsIjp…&sort=-createdAt&filter[state]=published
Response includes meta.page.{size, cursor, nextCursor, prevCursor, totalApproximate}.
6. Rate limiting
| Endpoint family | Learner | Author | System account |
|---|---|---|---|
GET /quiz-banks/* (serve) | 60 req/min per user | 600/min | unlimited |
POST /attempts/*/score | 30 req/min per user | — | unlimited |
POST /quiz-banks/*/ai/generate | — | 20/min per user, tenant budget enforced | — |
| Authoring writes | — | 120 req/min per user | — |
Rate limit headers per doc 05 §12:
X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After
7. Error schema (problem+json)
{
"type": "https://errors.ghasi.com/assessment/quiz_bank.draft_not_servable",
"title": "Cannot serve draft quiz bank",
"status": 409,
"code": "quiz_bank.draft_not_servable",
"detail": "Quiz bank qbk_01HN… is in state 'draft'. Only 'published' banks can be served.",
"instance": "/api/v1/quiz-banks/qbk_01HN…/questions",
"traceId": "00-abc…-01",
"requestId": "req_…"
}
Full error code table in APPLICATION_LOGIC.md §6.
8. Internal service-to-service endpoints
Hidden from tenant-facing API gateway; reachable only via mesh sidecar mTLS with specific SPIFFE identity.
| Endpoint | Caller | Purpose |
|---|---|---|
POST /internal/attempts/bulk-score | sync-service | Batch offline replay |
POST /internal/tenants/{id}/purge | tenant-service | Tenant off-boarding |
GET /internal/quiz-banks/{id}/answer-key | (none) | Forbidden — answer keys never leave the service; only score() consumes them |
9. Contract testing
- Producer Pact tests for every consumer (delivery-service, progress-service, certification-service, sync-service).
- OpenAPI lint (Spectral) in CI; breaking changes blocked on main branch.
- Content-shape linter asserts presentation responses never contain answer keys. Runs on every CI build against recorded fixtures.
- Contract fixtures versioned in
/contracts/assessment/v1/*.json.
10. References
- Platform API conventions: docs/05-api-design.md
- Domain types referenced here: DOMAIN_MODEL.md
- Event schemas that accompany these endpoints: EVENT_SCHEMAS.md