Skip to main content

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

PathMethodPurposeAuthIdempotentAuthZ
/api/v1/quiz-banksPOSTCreate a new quiz bank (author)JWTRequired (header)quiz_bank:create
/api/v1/quiz-banks/{id}GETRead a quiz bank (author view — includes answers)JWTYesquiz_bank:read
/api/v1/quiz-banks/{id}PATCHUpdate metadata or grading ruleJWTRequiredquiz_bank:update
/api/v1/quiz-banks/{id}/publishPOSTPublish (state: draft → published)JWTRequiredquiz_bank:publish
/api/v1/quiz-banks/{id}/archivePOSTArchive (state: * → archived)JWTRequiredquiz_bank:archive
/api/v1/quiz-banks/{id}/questionsGETServe presentation form (answer-stripped, randomized) for a learner attemptJWTYesquiz_bank:serve (internal scope)
/api/v1/quiz-banks/{id}/questionsPOSTAdd a questionJWTRequiredquiz_bank:update
/api/v1/questions/{id}PATCHUpdate a questionJWTRequiredquiz_bank:update
/api/v1/questions/{id}/deactivatePOSTSoft-delete questionJWTRequiredquiz_bank:update
/api/v1/quiz-banks/{id}/ai/generatePOSTDraft questions with AI (HITL required)JWTRequiredquiz_bank:ai_generate
/api/v1/attempts/{id}/submit-responsePOSTSubmit a single response (buffered)JWTRequiredattempt:submit_response (self)
/api/v1/attempts/{id}/scorePOSTClose attempt and compute scoreJWTRequiredattempt:score (self or system)
/api/v1/attempts/{id}GETRead attempt result (learner sees self; admin sees all in tenant)JWTYesattempt:read
/api/v1/attempts/{id}/regradePOSTTrigger regrade (admin)JWTRequiredattempt:regrade
/api/v1/attempts/{id}/responses/{qid}/human-gradePOSTHuman-override AI gradeJWTRequiredattempt:human_grade
/api/v1/branching-scenariosPOSTCreate scenarioJWTRequiredscenario:create
/api/v1/branching-scenarios/{id}GETRead scenario (author view)JWTYesscenario:read
/api/v1/branching-scenarios/{id}PATCHUpdate metadata/scoringJWTRequiredscenario:update
/api/v1/branching-scenarios/{id}/nodesPOSTAdd nodeJWTRequiredscenario:update
/api/v1/branching-scenarios/{id}/nodes/{nid}PATCHUpdate nodeJWTRequiredscenario:update
/api/v1/branching-scenarios/{id}/publishPOSTPublish scenario (runs DAG + reachability checks)JWTRequiredscenario:publish
/api/v1/branching-scenarios/{id}/navigatePOSTNavigate a choice (stateless server evaluation)JWTYes (pure)scenario:navigate (self)
/api/v1/branching-scenarios/{id}/ai/generatePOSTDraft scenario with AIJWTRequiredscenario: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_mismatch
  • 403 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, pairs with 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_buffer table keyed by (attemptId, questionId); last-write-wins per question.
  • Idempotent on (Idempotency-Key); same key returns 200 with 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_scored
  • 422 attempt.expired — if submittedAt − startedAt > timeLimit + grace
  • 422 bank.version_mismatch — if quizBankVersion no longer exists
  • 403 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 QuizBank state until author accepts and posts PATCH /quiz-banks/{id} (HITL).
  • Error 402 ai.budget_exceeded if tenant AI budget exhausted.
  • Error 422 ai.output_invalid if 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 familyLearnerAuthorSystem account
GET /quiz-banks/* (serve)60 req/min per user600/minunlimited
POST /attempts/*/score30 req/min per userunlimited
POST /quiz-banks/*/ai/generate20/min per user, tenant budget enforced
Authoring writes120 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.

EndpointCallerPurpose
POST /internal/attempts/bulk-scoresync-serviceBatch offline replay
POST /internal/tenants/{id}/purgetenant-serviceTenant 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