Skip to content

Question Service API (question-bank-service alias)

SVC-008 Save/Reload Route Proof

See Question Save/Reload Route Proof for the accepted bounded update/reload evidence, native route guards, and the remaining live/browser/default-route gate.

Current Endpoints

  • GET /healthz
  • GET /readyz
  • GET /v1
  • GET /v1/question-groups
  • POST /v1/question-groups
  • GET /v1/question-types
  • POST /v1/question-types
  • PATCH /v1/question-types/bulk-status
  • POST /v1/question-types/bulk-delete
  • PATCH /v1/question-types/{id}
  • DELETE /v1/question-types/{id}
  • GET /v1/taxonomy/refs
  • PUT /v1/taxonomy/refs
  • GET /v1/questions
  • POST /v1/questions
  • POST /v1/questions/ai-preview-drafts
  • POST /v1/questions/ai-preview-solutions
  • PATCH /v1/questions/bulk-status
  • POST /v1/questions/permanent-delete-impact
  • POST /v1/questions/permanent-delete
  • POST /v1/questions/permanent-delete/jobs
  • GET /v1/questions/permanent-delete/jobs/{id}
  • GET /v1/questions/permanent-delete/jobs/{id}/events
  • POST /v1/questions/permanent-delete/jobs/{id}/cancel
  • GET /v1/questions/search-projections
  • GET /v1/questions/{id}
  • PATCH /v1/questions/{id}
  • DELETE /v1/questions/{id}
  • PUT /v1/questions/usage-counts
  • PUT /v1/questions/relation-refs
  • PUT /v1/questions/exam-refs
  • POST /v1/internal/questions/select-for-exam-blueprint
  • POST /v1/questions/ai-classify/context
  • PATCH /v1/questions/ai-classify/apply
  • POST /v1/questions/import-docx-output

Exam Blueprint Selection Contract

POST /v1/internal/questions/select-for-exam-blueprint is a trusted service-to-service contract for exam-service, not a frontend or public Gateway route.

  • Question Bank applies normal tenant/actor scope, published-only status, curriculum/source/type/difficulty filters, chapterId, topicId, optional grouped-question requirement, and duplicate exclusion.
  • Rule selection is ordered. A randomSeed produces a deterministic shuffled result for one RANDOM_PER_EXAM generation; without it the normal newest first ordering remains stable.
  • The response is hydrated with current version, options, answer keys, media, and formula refs. exam-service snapshots those values by value and never reads Question Bank storage directly.
  • Selection keeps its ordered, duplicate-safe rule evaluation, then batches custom-type, taxonomy, usage, and relation read-model hydration across the completed result. This avoids repeating those lookups once per matrix rule without changing the returned snapshots.

Teacher-Approved AI Preview Draft

POST /v1/questions/ai-preview-drafts is the owner-service write boundary for a teacher-reviewed generation preview. The teacher UI reaches it only through the exact opt-in Gateway route.

  • Question Bank forces status=DRAFT, source=AI preview, and sourceType=AI_GENERATED; provider/model/generation mode and the reviewed source metadata are stored as provenance.
  • The idempotency scope is (organizationId, actorId, previewId). The same reviewed payload returns the original draft with 200; a new draft returns 201; reuse of previewId with different reviewed content is rejected with 400.
  • A hard delete removes the service-owned idempotency record with the question, allowing the teacher to create a new draft from that preview. An archive keeps the accepted draft intact.
  • The endpoint does not publish a question, debit usage, or apply an AI solution to another canonical question.

Teacher-Approved AI Preview Solution

POST /v1/questions/ai-preview-solutions is the owner-service write boundary for a teacher-approved AI explanation on an existing canonical question. The teacher UI can reach it only through the exact opt-in Gateway route.

  • The request requires a canonical questionId, a client-stable previewId, and reviewed solution. Question Bank appends a replacement question_versions row and moves currentVersionId; it never changes the original question source/import provenance and never publishes the question.
  • Explanation JSON records source=ai-preview, previewId, provider/model when supplied, and normalized solution steps. The durable idempotency scope is (organizationId, actorId, previewId); a replay returns 200, while a different solution for the same preview is rejected with 400.
  • Tenant and owner scope are enforced before the write. A teacher cannot apply a preview to another teacher's private Question Bank row. Hard deletion removes the companion idempotency record with the question.
  • This endpoint does not call an AI provider, debit usage, or replace broad /api/questions legacy traffic. AI only provides the preview; Question Bank is the canonical solution/version owner.

Question Group Compatibility

GET /v1/question-groups and POST /v1/question-groups are the first native foundation for legacy /api/question-groups.

  • GET /v1/question-groups applies tenant and owner scope from X-Organization-Id, X-User-Id, and X-User-Role; allOrgs=1 is honored only for ADMIN actors.
  • GET /v1/question-groups?q= filters over title and content text.
  • Responses include legacy _count.questions so grouped passage/context lists can show usage without joining exam or attempt databases.
  • POST /v1/question-groups requires organization and actor scope, plus title and contentText; it stores rich contentJson, mediaJson, and source metadata in question-bank-service ownership.

Default gateway routes for /api/question-groups remain legacy until the broader question write-parity slice is promoted.

Question Folder Compatibility

GET /v1/question-folders, POST /v1/question-folders, PATCH /v1/question-folders/{id}, and DELETE /v1/question-folders/{id} are the native foundation for legacy /api/question-folders.

  • Folder routes apply tenant and owner scope from X-Organization-Id, X-User-Id, and X-User-Role; allOrgs=1 is honored only for ADMIN actors.
  • POST /v1/question-folders requires name, stores optional parentId, and accepts visibility=PRIVATE|ORGANIZATION|SHARED_LIBRARY|PUBLIC.
  • PATCH /v1/question-folders/{id} updates name and parentId; an empty parentId clears the parent folder.
  • DELETE /v1/question-folders/{id} hard-deletes only empty folders. Folders with child folders or questions are rejected to avoid implicit question ownership changes.
  • Manual POST /v1/questions now accepts folderId after validating the folder is visible in the same question-bank-service tenant/actor scope.

Default gateway routes for /api/question-folders remain legacy until browser save/reload proof and rollback notes are complete.

Question Type Compatibility

/v1/question-types mirrors legacy /api/question-types list and write semantics for the native foundation:

  • Query summary=true or summary=1 defaults status to ACTIVE and omits usageCount.
  • Query status=ACTIVE|ARCHIVED filters by definition status.
  • Query q= filters case-insensitively over code and name.
  • When X-User-Id is present, custom definitions are scoped to that actor.
  • Response uses the legacy success envelope:
json
{
  "success": true,
  "data": [],
  "message": "OK"
}

Native write endpoints:

  • POST /v1/question-types creates actor-owned custom definitions, uppercases code, defaults status to ACTIVE, and rejects duplicate custom code for the same organization and actor.
  • PATCH /v1/question-types/{id} updates custom definitions owned by the actor. System definitions can be updated only by ADMIN actors and ignore code, baseType, and isSystem changes.
  • DELETE /v1/question-types/{id} archives system definitions or definitions with usageCount > 0; unused custom definitions are hard-deleted and return { "id": "...", "deleted": true }.
  • PATCH /v1/question-types/bulk-status and POST /v1/question-types/bulk-delete deduplicate non-empty IDs, then apply the same single-item authorization and validation.

Writes require X-Organization-Id, X-User-Id, and X-User-Role (ADMIN or TEACHER). The default public gateway route remains legacy; native writes are available only through the non-default route-table examples until browser parity and route promotion are approved.

The native read model is service-owned. On startup/request it uses the question_type_definitions table when DATABASE_URL is reachable, otherwise it falls back to the in-memory store used by local go run tests. The store seeds active core definitions from legacy systemQuestionTypeDefinitions for the current X-Organization-Id scope:

  • SINGLE_CHOICE
  • MULTIPLE_CHOICE
  • TRUE_FALSE
  • TRUE_FALSE_GROUP
  • SHORT_ANSWER
  • SHORT_NUMERIC_ANSWER
  • ESSAY
  • PASSAGE_READING

Legacy THPT/DGNL/template preset codes stay out of summary results. They are normalized by legacy import/review code to active core definitions and should not be reintroduced as active system definitions.

X-Organization-Id scopes system definition rows. Requests without the header keep global deterministic IDs equal to the core code; requests with the header get deterministic org-scoped IDs so service-owned rows do not collide between tenants. X-User-Id is recorded as createdById when a scoped seed is created.

Database

services/question-bank-service/migrations/000002_question_type_definitions.sql creates:

  • question_type_definitions
  • unique expression index for system definitions on (COALESCE(organization_id, ''), code)
  • unique expression index for custom definitions on (COALESCE(organization_id, ''), COALESCE(created_by_id, ''), code)
  • indexes for (organization_id, status), (is_system, name), and created_by_id

services/question-bank-service/migrations/000008_question_type_definition_actor_scope.sql drops the earlier broad scope/code unique index for already-migrated databases and installs the actor-scoped indexes above.

usage_count on question_type_definitions is a definition-level read model initialized to 0. Per-question legacy _count fields are handled separately by the question_usage_counts projection below.

Taxonomy Reference Read Model

GET /v1/taxonomy/refs and PUT /v1/taxonomy/refs are internal migration/backfill endpoints for service-owned question-bank taxonomy refs. They cover the legacy taxonomy family assigned to question-bank-service:

  • Subject
  • Chapter
  • Topic
  • EducationLevel
  • Grade
  • Curriculum
  • CurriculumNode
  • ExamTrack
  • QuestionSource
  • DifficultyLevel

PUT /v1/taxonomy/refs accepts:

json
{
  "items": [
    {
      "id": "subject_math",
      "kind": "SUBJECT",
      "code": "MATH",
      "name": "Toán"
    }
  ]
}

If an item omits organizationId, the endpoint uses X-Organization-Id as the default tenant scope. GET /v1/taxonomy/refs supports kind, status, q, and includeGlobal=1.

services/question-bank-service/migrations/000005_taxonomy_refs.sql creates question_taxonomy_refs, a denormalized read model for the shared legacy taxonomy fields. Real legacy-to-native backfill remains a later migration slice.

Question Usage Count Read Model

PUT /v1/questions/usage-counts is an internal migration/backfill endpoint for service-owned per-question usage counts. It accepts already-computed counters from a migrator, projection worker, or later exam/attempt event consumer:

json
{
  "items": [
    {
      "questionId": "q_123",
      "examItems": 2,
      "attemptItems": 7,
      "answers": 7
    }
  ]
}

If an item omits organizationId, the endpoint uses X-Organization-Id as the default tenant scope. Counts must be non-negative. The question-bank-service stores these values in its own question_usage_counts table and never joins exam/attempt databases while serving question reads.

Native GET /v1/questions and GET /v1/questions/{id} hydrate _count from this projection when a matching row exists. Missing rows keep the legacy-compatible zero placeholder from Question.ApplyListCompatibilityDefaults.

services/question-bank-service/migrations/000006_question_usage_counts.sql creates question_usage_counts, scoped by (organization_id, question_id), with non-negative counters and updated_at.

Question Relation Read Models

PUT /v1/questions/relation-refs is an internal migration/backfill endpoint for denormalized user and organization refs needed by native question reads:

json
{
  "users": [
    {
      "id": "teacher_1",
      "fullName": "Nguyễn Văn A",
      "email": "[email protected]"
    }
  ],
  "organizations": [
    {
      "id": "org_1",
      "name": "Trường THPT A",
      "slug": "truong-thpt-a"
    }
  ]
}

If a user item omits organizationId, the endpoint uses X-Organization-Id as the default tenant scope. If an organization item omits organizationId, the endpoint uses the item id as the natural tenant scope before falling back to X-Organization-Id. The question-bank-service stores these projections in its own question_user_refs and question_organization_refs tables and never joins profile-service, school-service, or legacy databases while serving question reads.

Native GET /v1/questions and GET /v1/questions/{id} hydrate createdBy and organization from these projections when matching rows exist. Missing rows keep the legacy-compatible placeholder behavior from Question.ApplyListCompatibilityDefaults.

services/question-bank-service/migrations/000007_question_relation_refs.sql creates question_user_refs and question_organization_refs, each scoped by (organization_id, id).

Question Exam Reference Read Model

PUT /v1/questions/exam-refs is an internal migration/projection endpoint for denormalized exam metadata needed by permanent-delete impact previews:

json
{
  "exams": [
    {
      "questionId": "q_123",
      "examId": "exam_1",
      "title": "Đề kiểm tra giữa kỳ",
      "status": "PUBLISHED",
      "createdAt": "2026-07-08T10:00:00Z"
    }
  ]
}

If an item omits organizationId, the endpoint uses X-Organization-Id as the default tenant scope. The question-bank-service stores these projections in its own question_exam_refs table and never joins exam-service or legacy exam databases while serving impact previews.

POST /v1/questions/permanent-delete-impact groups matching exam refs into affectedExams, including id, title, status, createdAt, and affectedQuestions. Missing projection rows leave affectedExams empty while affectedExamCount can still come from question_usage_counts.

services/question-bank-service/migrations/000010_question_exam_refs.sql creates question_exam_refs, scoped by (organization_id, question_id, exam_id).

services/question-bank-service/migrations/000003_question_core.sql creates the Phase 6 core native question tables:

  • question_groups
  • questions
  • question_versions
  • question_options
  • question_media_refs
  • question_formula_refs
  • tags
  • question_tags

services/question-bank-service/migrations/000004_question_list_compat.sql adds nullable legacy list/read reference fields to questions for taxonomy/source filters:

  • education_level_id, grade_id, curriculum_id, curriculum_node_id
  • exam_track_id, question_source_id, difficulty_level_id
  • chapter_id, topic_id
  • source_url, source_teacher_id

Native Manual Question Create

Phase 6 native endpoint:

  • POST /v1/questions

This endpoint creates a manual/native question inside question-bank-service. It preserves the legacy AppDataQuestionWriteService.createQuestion invariant: create the Question, initial QuestionVersion, QuestionOption rows, media/formula refs, and tags in the question-bank transaction, then set currentVersionId. It is service-native write parity only; default public /api/questions traffic stays gateway-controlled until browser editor save/reload parity is proven.

Required headers:

  • X-Organization-Id
  • X-User-Id
  • X-User-Role: ADMIN|TEACHER

Request compatibility:

  • Required body field: content.
  • Defaults match the legacy shared schema: type=SINGLE_CHOICE, subjectId=math, gradeLevel=12, difficulty=MEDIUM, sourceType=MANUAL, visibility=PRIVATE, and status=DRAFT.
  • Preserves rich contentJson, explanationJson, taxonomy/source IDs, customTypeId, answerKeys, scoringRule, subItems, ordered options, mediaRefs, formulaRefs, tags, and sourceMetadataJson.
  • answerKeysJson, scoringRuleJson, and subItemsJson are accepted as internal compatibility aliases.
  • If status=PUBLISHED, native validation enforces the legacy base-type constraints for choice, short-answer, true/false group, and essay questions.
  • folderId is accepted only after native question-service validates the folder in the same tenant/actor scope.

Native Question Update, Archive And Bulk Status

Phase 6 native endpoints:

  • PATCH /v1/questions/{id}
  • DELETE /v1/questions/{id}
  • PATCH /v1/questions/bulk-status
  • POST /v1/questions/permanent-delete-impact
  • POST /v1/questions/permanent-delete
  • POST /v1/questions/permanent-delete/jobs
  • GET /v1/questions/permanent-delete/jobs/{id}
  • GET /v1/questions/permanent-delete/jobs/{id}/events
  • POST /v1/questions/permanent-delete/jobs/{id}/cancel

These endpoints provide the conservative native foundation for legacy update, archive, and bulk status actions. They require X-Organization-Id, X-User-Id, and X-User-Role: ADMIN|TEACHER, use the same tenant/actor scope as native manual create, and preserve question-service ownership boundaries.

PATCH /v1/questions/{id} accepts a partial manual-question payload. Omitted fields preserve current values; supplied options, tags, mediaRefs, and formulaRefs are full replacements. Each successful update appends a new question_versions row, moves currentVersionId, and returns the hydrated question with message Question updated. Updating plain content or explanation without rich JSON regenerates matching rich text JSON for the new current version.

DELETE /v1/questions/{id} mirrors the legacy archive-vs-hard-delete branch without crossing service ownership. If projected _count.examItems, _count.attemptItems, _count.answers, or same-organization clone refs exist, the question is archived. Otherwise the service clears currentVersionId and hard-deletes the question-bank-owned row; local cascades remove versions, options, media/formula refs, and question-tag links. It never deletes exam-service, attempt-service, course-service, or other owner-service data.

Archive branch response:

json
{
  "success": true,
  "data": {
    "id": "question_id",
    "deleted": false,
    "archived": true
  },
  "message": "Question deleted"
}

Hard-delete branch response:

json
{
  "success": true,
  "data": {
    "id": "question_id",
    "deleted": true,
    "archived": false
  },
  "message": "Question deleted"
}

PATCH /v1/questions/bulk-status accepts up to 100 non-empty IDs and status=DRAFT|PUBLISHED|ARCHIVED. IDs are deduplicated before update. Rows outside the actor/tenant scope are skipped; the response includes the deduplicated request count and updated count.

POST /v1/questions/permanent-delete accepts up to 100 IDs, deduplicates them, includes archived rows, skips rows outside the actor/tenant scope, and hard-deletes only question-bank-service-owned rows/projections. It intentionally does not emulate the legacy forced cleanup that removes ExamQuestion, ExamAttemptQuestion, ExamAnswer, or CourseLessonQuestion rows from other owners. Response data is { "requested": 2, "deleted": 2 }.

POST /v1/questions/permanent-delete-impact accepts either selected ids or { "mode": "all" } for the first scoped archived batch. It returns the legacy preview shape with questions, affectedExams, and affectedAttemptCount; affectedExams is hydrated from service-owned question_exam_refs when exam-service or migration tooling has published detailed metadata projections, while affectedExamCount and affectedAttemptCount are computed from service-owned question_usage_counts.

POST /v1/questions/permanent-delete/jobs creates a service-local job for the same permanent-delete behavior. It accepts selected ids or { "mode": "all" }, deduplicates selected IDs, scopes by X-Organization-Id, X-User-Id, and X-User-Role, then processes archived question-bank-owned rows in native batches. The response exposes legacy-compatible progress fields including status, phase, totalQuestions, processedQuestions, deletedCount, failedCount, currentBatch, progressPercent, timestamps, warnings, and an optional error.

GET /v1/questions/permanent-delete/jobs/{id} returns the current service-local job snapshot. GET /v1/questions/permanent-delete/jobs/{id}/events emits an SSE snapshot event and short-lived job.updated events for live progress polling. POST /v1/questions/permanent-delete/jobs/{id}/cancel cancels pending or running native jobs; completed jobs are returned unchanged.

These endpoints never delete exam snapshots or attempt data. Service-local job routes now exist, but durable broker/BullMQ parity, public route promotion, browser save/reload proof, and rollback notes remain pending.

Native DOCX Output Ingestion

Phase 6 native endpoint:

  • POST /v1/questions/import-docx-output

This endpoint is an internal service-to-service contract from docx-import-service output into question-bank-service; it is not a cutover for legacy /api/questions yet. The write path preserves the legacy invariant from AppDataQuestionWriteService.createQuestion and createApprovalQuestion: create the Question, QuestionVersion, and QuestionOption rows in one transaction, then set currentVersionId.

Compatibility rules for the first native ingestion cut:

  • Keep group_id from DOCX output as grouping metadata and/or question_groups relation; do not infer semantic question type from grouping metadata alone.
  • Normalize known legacy template/preset codes to active core question types.
  • Preserve media references as media_asset_id/URL/object metadata owned by document-service; do not copy binary media into question-bank.
  • Preserve formula html and latex in question-bank formula references and version JSON.
  • Store source document/job/file metadata in sourceMetadataJson.
  • Rollback remains route-level: keep default /api/questions and /api/exam-import/jobs/:id/approve traffic on legacy until native approval cutover is explicitly verified. P4-007 provides a non-default gateway route example for the approval path only.

Required headers:

  • X-Organization-Id
  • X-User-Id
  • Idempotency-Key is optional but sent by the current Import approval path. Without it, sourceImportJobId is the compatibility replay identity.

The request body accepts the docx-import-service QAS output shape (document_id, latency_ms, questions, warnings, stats) plus optional question-bank metadata such as subjectId, gradeLevel, sourceFileName, sourceImportJobId, sourceMetadataJson, and tags.

Question Bank owns a durable ingest ledger keyed by organization, source import job, and canonical request digest. The ledger and all groups, questions, versions, options, media, and formula references commit in one owner transaction. An identical retry returns the original documentId and questionIds; an Idempotency-Key rebound to another payload returns 409. For TRUE_FALSE_GROUP, the owner persists ordered A through D subitems with content and correctBoolean, accepting both parser boolean maps and the real DOCX compact value form such as a:ĐÚNG;b:SAI;c:ĐÚNG;d:SAI.

Native Question List

GET /v1/questions returns hydrated native rows in the legacy-style success envelope:

json
{
  "success": true,
  "data": {
    "items": [],
    "meta": {
      "page": 1,
      "limit": 20,
      "total": 0,
      "totalPages": 1
    }
  },
  "message": "OK"
}

Supported filters in this Phase 6 slice: q, page, limit, subjectId, gradeLevel, difficulty, educationLevelId, gradeId, curriculumId, curriculumNodeId, examTrackId, questionSourceId, difficultyLevelId, type, customTypeId, sourceType, status, includeArchived, allOrgs, unclassifiedOnly, missingExplanationOnly, fidelityStatus, sourceMetadataSource, createdById, and sourceImportJobId. q searches question content/text, source name/file name, and tag name. Rows are hydrated with currentVersion, options, mediaRefs, formulaRefs, tags, customType, and taxonomy named refs when present.

Scoping follows the legacy list guardrails where the actor is available: non-admin reads are owner-scoped when X-User-Id is present, and allOrgs=1 bypasses organization scope only when X-User-Role is ADMIN. Native rows hydrate customType from active native question_type_definitions by customTypeId, definition code, or core type. Native rows also hydrate subject, chapter, topic, educationLevel, grade, curriculum, curriculumNode, examTrack, questionSource, and difficultyLevel from active question_taxonomy_refs by ID when that read model has data. Native rows hydrate _count from question_usage_counts and createdBy/organization from question_user_refs/question_organization_refs when projection rows exist; missing projections stay placeholders.

This is still not a full /api/questions cutover. Manual native create, update, archive-vs-hard-delete, permanent-delete impact/batch, and bulk-status foundations exist at POST /v1/questions, PATCH /v1/questions/{id}, DELETE /v1/questions/{id}, POST /v1/questions/permanent-delete-impact, POST /v1/questions/permanent-delete, POST /v1/questions/permanent-delete/jobs, GET /v1/questions/permanent-delete/jobs/{id}, GET /v1/questions/permanent-delete/jobs/{id}/events, POST /v1/questions/permanent-delete/jobs/{id}/cancel, and PATCH /v1/questions/bulk-status, but real legacy taxonomy, usage-count, and relation backfill, browser parity, route-table cutover proof, and durable permanent-delete broker/worker semantics remain pending.

Question Search Projection Backfill

GET /v1/questions/search-projections is the owner-service backfill contract for search-service question rebuilds. It applies the same scope and filters as GET /v1/questions, then returns copied QUESTION search documents:

json
{
  "success": true,
  "data": {
    "sourceService": "question-bank-service",
    "entityType": "QUESTION",
    "documents": [],
    "nextCursor": null,
    "hasMore": false,
    "meta": {
      "page": 1,
      "limit": 20,
      "total": 0,
      "totalPages": 1
    }
  },
  "message": "OK"
}

When status is omitted, the route includes archived questions so full search rebuilds do not silently drop delete/archive state. status=PUBLISHED and the other native list filters can still narrow a targeted projection check.

The route preserves the service boundary: search-service consumes copied documents over HTTP or events and stores its own index rows. It must not read question-bank-service tables directly. This endpoint is internal backfill groundwork only; no public gateway search route is promoted by its existence.

Durable Search Projection Outbox

services/question-bank-service/migrations/000015_question_search_projection_outbox.sql adds a service-owned revision table and durable outbox for search.projection.changed.v1 delivery. A Postgres trigger covers canonical question INSERT, UPDATE, and DELETE inside the same transaction as the owner write. It assigns a strictly increasing per-question sourceVersion, stores a stable event id, and writes a copied QUESTION envelope without answer keys, access credentials, or raw source metadata.

Repeated UPSERT mutations for the same question in one database transaction are coalesced into one outbox row whose payload reflects the final question row. DELETE creates a higher-version tombstone. A rolled-back owner transaction leaves neither canonical data nor an outbox row. With QUESTION_SEARCH_PROJECTION_EVENT_TRANSPORT=nats, Question Bank leases due outbox rows and publishes the exact persisted envelope to JetStream stream SEARCH_PROJECTIONS on search.projection.changed.v1. Leases, retries, and dead rows are delivery state only; they do not change the canonical write. That publisher is disabled by default, currently covers Question Bank only, and does not add a Gateway route. GET /v1/questions/search-projections remains the backfill and reconciliation path.

bash
QUESTION_SEARCH_PROJECTION_OUTBOX_POSTGRES_CONFIRM=disposable-postgres \
  make test-question-search-projection-outbox-postgres
bash
SEARCH_PROJECTION_BROKER_SMOKE_CONFIRM=disposable-runtime \
  make test-search-projection-broker-live

Legacy Question Read Adapter

GET /v1/legacy/questions and GET /v1/legacy/questions/{id} are internal compatibility endpoints for a later gateway cutover of GET /api/questions and GET /api/questions/:id.

They reuse the same native filters and scope headers as /v1/questions, but project the response into the legacy read surfaces:

  • list items include scalar question fields, subject/chapter/topic name refs, customType.name/baseType, createdBy.fullName/email, organization.id/name/slug, and _count.examItems/attemptItems/answers
  • list items intentionally omit full native internals such as currentVersion, options, mediaRefs, and formulaRefs
  • list scope preserves native question read policy: allOrgs=1 is honored only when X-User-Role=ADMIN; non-admin actors remain owner/org-scoped
  • normal detail includes legacy-style scalar fields, group, customType, createdBy, currentVersion, ordered options, and tag wrappers
  • GET /v1/legacy/questions/{id}?view=editor reuses the legacy editor-selected projection from /v1/questions/{id}?view=editor

Default gateway routes still point /api/questions at legacy. The non-default shadow-read and native-read route examples now target /v1/legacy/questions; rerun shadow/native/browser parity with real backfilled data before any public cutover.

Native Question Detail And Editor Read

GET /v1/questions/{id} returns one hydrated native row in the same success envelope:

json
{
  "success": true,
  "data": {
    "id": "q_...",
    "currentVersion": {},
    "options": [],
    "mediaRefs": [],
    "formulaRefs": [],
    "tags": []
  },
  "message": "OK"
}

Legacy evidence:

  • QuestionsController.detail serves GET /api/questions/:id and switches to the editor projection when view=editor.
  • AppDataQuestionReadService.getQuestion includes subject, chapter, topic, group, customType, createdBy, currentVersion, ordered options, and tags.
  • AppDataQuestionReadService.getQuestionForEditor selects rich content/explanation JSON, taxonomy/source IDs, source metadata, group passage fields, currentVersion.answerKeysJson, currentVersion.subItemsJson, and ordered option content.
  • Frontend editor code calls /questions/{id}?view=editor; preview/generation flows call /questions/{id}.

Native Phase 6 behavior:

  • view=editor is accepted on GET /v1/questions/{id} and returns the legacy editor-selected shape: rich content/explanation JSON, taxonomy/source IDs, group passage fields, currentVersion.answerKeysJson, currentVersion.subItemsJson, and ordered option editor fields.
  • The same scope headers and query fallbacks as list reads are honored: X-Organization-Id, optional organizationId, X-User-Id, X-User-Role, and admin-only allOrgs=1.
  • Non-admin reads are owner-scoped when X-User-Id is present, matching the legacy questionOwnerScope guardrail.
  • Detail reads include archived rows by default when the actor/tenant scope matches, matching legacy getQuestion and getQuestionForEditor.
  • Detail reads hydrate customType with the same native definition lookup as list reads.
  • Detail reads hydrate subject, chapter, topic, educationLevel, grade, curriculum, curriculumNode, examTrack, questionSource, and difficultyLevel from active taxonomy refs when present.
  • Missing or out-of-scope IDs return 404 QUESTION_NOT_FOUND.

This endpoint is native-read parity groundwork, not a public write cutover. Native PATCH /v1/questions/{id} now covers rich update/version replacement internally, but legacy /api/questions/:id update/delete and editor save remain on the old API until browser save/reload, route-table, and rollback evidence are complete.

The named non-default question write proof gate is docs/qa/question-write-route-rehearsals.md. Agents must run make test-question-write-routes, QUESTION_WRITE_SELF_TEST=1 make test-question-write-live, and QUESTION_WRITE_BROWSER_SELF_TEST=1 make test-question-write-browser before requesting public editor save/reload promotion. The real live/browser modes still require a running gateway/frontend, auth/org context, seeded editable question data, artifacts, and rollback notes.

Native AI Classification Context

POST /v1/questions/ai-classify/context returns internal prompt input for native AI classification providers. It accepts:

  • questionIds with at most 25 ids.
  • fields using the supported classification fields.
  • mode as missing or all; all forces overwrite=true.
  • overwrite, onlyUnclassified, and fullText.

The endpoint returns scoped question rows in request order with bounded content, explanation, options, current classification, candidate grades, and question-specific candidate curriculum nodes. Shared taxonomy context comes from active service-owned question type definitions and taxonomy refs with global fallback. Curriculum node candidates mirror the legacy prompt guardrails: subject, grade, and curriculum matching first, then subject/grade fallback, capped at 160 nodes.

onlyUnclassified uses the legacy AI job predicate, not the general question-list unclassifiedOnly filter: a row qualifies when required classification refs are missing or the current difficulty came from the legacy deterministic difficulty sync reason. In that deterministic difficulty case, the response sets needsDifficultyReclassification=true and returns an empty current difficultyLevelId.

This endpoint is internal prompt-context groundwork only. Public /api/questions/ai-classify/suggestions, /api/questions/ai-classify/jobs*, job SSE, job errors, and cancel routes remain legacy-proxied until ai-classifier-service consumes this context through an explicit service client and browser parity is proven.

Native AI Classification Apply

PATCH /v1/questions/ai-classify/apply accepts the legacy-shaped AI classification apply payload:

  • items[].questionId
  • items[].acceptedFields
  • items[].fields
  • threshold
  • optional taxonomy

The native foundation updates service-owned question classification fields and stores audit/history data under sourceMetadataJson.aiClassification. Supported direct fields are type, customTypeId, subjectId, educationLevelId, gradeId, curriculumId, curriculumNodeId, examTrackId, questionSourceId, and difficultyLevelId.

Native apply builds classification context from active service-owned question_type_definitions and question_taxonomy_refs with global fallback. The request taxonomy payload remains optional and can contribute candidates, but taxonomy-backed fields no longer require client-supplied context. Native apply mirrors the legacy derived field behavior:

  • customTypeId is normalized through native active question_type_definitions and applies the selected definition baseType to type.
  • subjectId, educationLevelId, curriculumId, curriculumNodeId, examTrackId, and questionSourceId accept taxonomy id, code, or name.
  • gradeId accepts taxonomy id, code, or name and applies numericLevel to gradeLevel.
  • difficultyLevelId accepts taxonomy id, code, or name and applies engineDifficulty to difficulty.

This is internal groundwork only. Public /api/questions/ai-classify/apply remains legacy-proxied until real legacy taxonomy backfill, gateway auth/RBAC, and browser parity are proven.

make test-question-classification-apply-live is the opt-in write smoke for the non-default gateway adapter. It requires explicit confirmation, a real native question id, auth/org context, and a gateway running with routes.question-classification-apply-native-localhost-example.json.

Gateway Rehearsal

Use deploy/gateway/routes.question-types-shadow-localhost-example.json to compare native exact GET /api/question-types reads while still returning the legacy response during local go run:

bash
GATEWAY_ROUTE_TABLE=deploy/gateway/routes.question-types-shadow-localhost-example.json \
LEGACY_API_BASE_URL=http://localhost:4001 \
HTTP_ADDR=:8085 \
go run ./services/api-gateway/cmd/server

Run question-bank-service on :8089, or in Docker Compose use the container-facing deploy/gateway/routes.question-types-shadow-example.json route table. For native read/write rehearsal, use deploy/gateway/routes.question-types-native-localhost-example.json or deploy/gateway/routes.question-types-native-example.json.

Static route-table coverage:

bash
make test-question-types-routes

Opt-in live gateway write smoke:

bash
QUESTION_TYPES_LIVE_CONFIRM=write-native \
QUESTION_TYPES_AUTHORIZATION='Bearer <token>' \
QUESTION_TYPES_ORGANIZATION_ID=<org-id> \
GATEWAY_BASE_URL=http://localhost:8085 \
make test-question-types-live

The live smoke requires the non-default native question-type route table. It checks /v1/routes, confirms a nested GET manager guard remains legacy, creates a custom type, updates it, bulk-archives it, and hard-deletes it. Hermetic validation uses QUESTION_TYPES_SELF_TEST=1 make test-question-types-live and does not contact a running gateway.

Rollback is changing GATEWAY_ROUTE_TABLE back to deploy/gateway/routes.json or deleting the question-types-read and question-types-write routes. The broad question-types route remains legacy_proxy in every non-default example so unlisted methods and nested GET question-type manager routes continue to hit legacy.

Full native cutover is not approved yet. Keep default public routing on legacy until /teacher/questions/types, import-editor consumers, auth/RBAC, browser parity, and rollback evidence are ready.

Go-platform documentation is generated from repository Markdown.