Skip to content

API Gateway Contract Notes

Public compatibility base path remains /api.

Current baseline routes:

  • /healthz
  • /readyz
  • /metrics
  • /api/health/live
  • /api/health/ready
  • /v1/routes
  • /api/* legacy proxy by route table

Route states:

  • legacy_proxy
  • shadow_read
  • native_read
  • native_write_shadow_validate
  • native_write
  • removed

Route Table Fields

json
{
  "name": "exam-import-docx-fast-create",
  "prefix": "/api/exam-import/docx-fast-jobs",
  "methods": ["POST"],
  "exact": true,
  "state": "native_write",
  "target": "http://docx-import-service:8080",
  "target_prefix": "/v1/import/docx/docx-fast-jobs",
  "require_auth": true,
  "required_roles": ["ADMIN", "TEACHER"],
  "require_organization": true
}

methods is optional. When present, only those HTTP methods match. exact is optional and defaults to false; set it to true when only the exact public path should match and subroutes must fall through to a broader legacy route. suffix_segments is optional and defaults to 0; set it to a positive value when a route should match exactly that many path segments after prefix. For example, prefix: "/api/questions" plus suffix_segments: 1 matches /api/questions/{id} but not /api/questions, /api/questions/{id}/edit, or /api/questions/ai-classify/jobs. suffix is optional and adds a literal final-segment guard when suffix_segments is used; for example, suffix: "/approve" keeps sibling actions such as /stop on a broader legacy route. target_prefix rewrites the public legacy-compatible path to the native service path. If it is omitted, the original path is forwarded unchanged.

require_auth, required_roles, and require_organization are optional route-level auth adapter fields. When a route opts in, the gateway verifies the legacy access token from Authorization or the hoctapaz.accessToken cookie using AUTH_JWT_SECRET, enforces any listed roles, derives organization from X-Organization-Id, organizationId, or the token default organization, and forwards X-User-Id, X-User-Role, X-User-Full-Name, optional X-User-Email, and X-Organization-Id to the native service. Omit these fields for public routes and for routes that must remain legacy-authenticated.

A local native-routing example lives at deploy/gateway/routes.native-example.json. It is intentionally not the default route table because the default must preserve current frontend contracts.

Auth session route rehearsal:

json
{
  "name": "auth-login",
  "prefix": "/api/auth/login",
  "methods": ["POST"],
  "exact": true,
  "state": "native_write",
  "target": "http://auth-service:8080",
  "target_prefix": "/v1/auth/login"
}

The Compose-facing auth rehearsal table is deploy/gateway/routes.auth-native-example.json; the local go run variant is deploy/gateway/routes.auth-native-localhost-example.json. It routes only the Phase 8 native session foundation paths to auth-service:

  • POST /api/auth/register
  • POST /api/auth/login
  • POST /api/auth/refresh
  • POST /api/auth/logout
  • GET /api/auth/me

These auth routes intentionally omit the gateway auth adapter because auth-service directly handles login/register bodies and bearer/refresh tokens. The broader /api/auth route remains legacy-proxied so Google auth, test-login, forgot/reset password, profile, KYC, and email-change routes do not move with this rehearsal. Rollback is returning GATEWAY_ROUTE_TABLE to deploy/gateway/routes.json or deleting the non-default auth route entries.

Run make test-auth-routes to statically validate the default and native auth route tables before any browser session rehearsal.

Organization read route rehearsal:

json
{
  "name": "organizations-list",
  "prefix": "/api/organizations",
  "methods": ["GET"],
  "exact": true,
  "state": "native_read",
  "target": "http://school-service:8080",
  "target_prefix": "/v1/organizations",
  "require_auth": true,
  "require_organization": true
}

The Compose-facing organization read rehearsal table is deploy/gateway/routes.organizations-read-native-example.json; the local go run variant is deploy/gateway/routes.organizations-read-native-localhost-example.json. It routes only read paths from the admin organization surface to school-service:

  • GET /api/organizations
  • GET /api/organizations/{organizationId}
  • GET /api/organizations/{organizationId}/units
  • GET /api/organizations/{organizationId}/members

These routes use the gateway auth adapter only for identity and organization header injection. They intentionally do not set global required_roles because legacy organization management is controlled by tenant membership roles (OWNER/ORG_ADMIN) rather than only the global JWT role. Organization writes, purge, unit/member mutations, student-import creation, the broad /api/organizations route, and fallback remain legacy-proxied. Rollback is returning GATEWAY_ROUTE_TABLE to deploy/gateway/routes.json or deleting the non-default organization route entries.

Run make test-organization-routes to statically validate the default and native organization read route tables before any live rehearsal.

Classroom adapter preflight guard:

The classroom-service Phase 8 foundation is intentionally not exposed through a public /api/classrooms* or /api/admin/classrooms* gateway route table yet. Legacy classroom list/detail/member/student/admin responses hydrate teacher, organization, subject, user, lesson, assignment, attempt, and deferred counter data that the current service-owned classroom rows do not provide. The default route table and all current rehearsal tables must therefore keep classroom public/admin routes on legacy_proxy.

Run make test-classroom-route-guard to statically validate that deploy/gateway/routes.json and non-default route-table examples do not accidentally promote /api/classrooms* or /api/admin/classrooms* to native or shadow states. Future classroom adapters must update this guard only after auth/RBAC, cross-service validation, response hydration, and deferred count contracts are implemented.

Student course read route rehearsal:

json
{
  "name": "student-courses-list",
  "prefix": "/api/student/courses",
  "methods": ["GET"],
  "exact": true,
  "state": "native_read",
  "target": "http://course-service:8080",
  "target_prefix": "/v1/student/courses",
  "require_auth": true,
  "required_roles": ["STUDENT"],
  "require_organization": true
}

The Compose-facing student-course read rehearsal table is deploy/gateway/routes.student-courses-read-native-example.json; the local go run variant is deploy/gateway/routes.student-courses-read-native-localhost-example.json. It routes only student course reads to course-service:

  • GET /api/student/courses
  • GET /api/student/courses/{courseId}

These routes use the gateway auth adapter for STUDENT role enforcement and organization header injection, matching the legacy TenantGuard surface. Recommendation, purchase, lesson progress, video progress, material view, broad /api/student/courses, broad /api/courses, broad /api/public, and fallback remain legacy-proxied. Rollback is returning GATEWAY_ROUTE_TABLE to deploy/gateway/routes.json or deleting the non-default student-course route entries.

Run make test-student-course-routes to statically validate the default and native student-course route tables before any live rehearsal.

Student course progress route rehearsal:

json
{
  "name": "student-course-lesson-progress",
  "prefix": "/api/student/courses",
  "methods": ["POST"],
  "suffix_segments": 4,
  "suffix": "/progress",
  "state": "native_write",
  "target": "http://course-service:8080",
  "target_prefix": "/v1/student/courses",
  "require_auth": true,
  "required_roles": ["STUDENT"],
  "require_organization": true
}

The Compose-facing student-course progress rehearsal table is deploy/gateway/routes.student-course-progress-native-example.json; the local go run variant is deploy/gateway/routes.student-course-progress-native-localhost-example.json. It routes only student-owned progress writes to course-service:

  • POST /api/student/courses/{courseId}/lessons/{lessonId}/progress
  • POST /api/student/courses/{courseId}/lessons/{lessonId}/video-progress
  • POST /api/student/courses/{courseId}/materials/{materialId}/view

These routes use the gateway auth adapter for STUDENT role enforcement and organization header injection. Student course list/detail are covered by the separate P8-008 read table and stay legacy in this progress-only table. Mastery, recommendation, purchase, broad /api/student/courses, broad /api/courses, broad /api/public, and fallback remain legacy-proxied. Rollback is returning GATEWAY_ROUTE_TABLE to deploy/gateway/routes.json or deleting the non-default student-course progress route entries.

Run make test-student-course-progress-routes to statically validate the default and native student-course progress route tables before any live rehearsal.

Notification route rehearsal:

json
{
  "name": "notifications-list",
  "prefix": "/api/notifications",
  "methods": ["GET"],
  "exact": true,
  "state": "native_read",
  "target": "http://notification-service:8080",
  "target_prefix": "/v1/notifications",
  "require_auth": true
}

The Compose-facing notification rehearsal table is deploy/gateway/routes.notifications-native-example.json; the local go run variant is deploy/gateway/routes.notifications-native-localhost-example.json. It routes only inbox and preference paths to notification-service:

  • GET /api/notifications
  • GET /api/notifications/{notificationId}
  • POST /api/notifications/{notificationId}/read
  • POST /api/notifications/{notificationId}/delete
  • POST /api/notifications/read-all
  • POST /api/notifications/delete-all
  • GET /api/notifications/preferences
  • PATCH /api/notifications/preferences
  • GET /api/alerts/preferences
  • PATCH /api/alerts/preferences

These routes use the gateway auth adapter for actor header injection but do not require organization at the gateway because the legacy notification inbox is primarily user-scoped and the shell notification bell fetches with only Authorization. Notification create, batch, snapshot, parent alerts, weak-topic alerts, admin inbox, broad /api/notifications, broad /api/alerts, broad /api/admin, and fallback remain legacy-proxied. Rollback is returning GATEWAY_ROUTE_TABLE to deploy/gateway/routes.json or deleting the non-default notification route entries.

Run make test-notification-routes to statically validate the default and native notification route tables before any live rehearsal.

Parent alert route rehearsal:

json
{
  "name": "parent-alerts-list",
  "prefix": "/api/parent/alerts",
  "methods": ["GET"],
  "exact": true,
  "state": "native_read",
  "target": "http://notification-service:8080",
  "target_prefix": "/v1/parent/alerts",
  "require_auth": true,
  "required_roles": ["PARENT"]
}

The Compose-facing parent-alert rehearsal table is deploy/gateway/routes.parent-alerts-native-example.json; the local go run variant is deploy/gateway/routes.parent-alerts-native-localhost-example.json. It routes only the parent-facing alert read surface to notification-service:

  • GET /api/parent/alerts
  • POST /api/parent/alerts/{notificationId}/read
  • POST /api/parent/alerts/read-all

These routes use the gateway auth adapter and global PARENT role check so notification-service receives parent actor headers. They do not require organization at the gateway because the legacy parent alert page fetches with only the current parent identity. Weak-topic alert creation, parent-child relationship lookup, broad /api/parent/alerts, broad /api/notifications, broad /api/alerts, broad /api/admin, and fallback remain legacy-proxied. Rollback is returning GATEWAY_ROUTE_TABLE to deploy/gateway/routes.json or deleting the non-default parent-alert route entries.

Run make test-parent-alert-routes to statically validate the default and native parent-alert route tables before any live rehearsal.

Feature maintenance route rehearsal:

json
{
  "name": "admin-feature-maintenance-list",
  "prefix": "/api/admin/feature-maintenance",
  "methods": ["GET"],
  "exact": true,
  "state": "native_read",
  "target": "http://admin-service:8080",
  "target_prefix": "/v1/admin/feature-maintenance",
  "require_auth": true,
  "required_roles": ["ADMIN"]
}

The Compose-facing feature-maintenance rehearsal table is deploy/gateway/routes.feature-maintenance-native-example.json; the local go run variant is deploy/gateway/routes.feature-maintenance-native-localhost-example.json. It routes only the native feature-maintenance public/admin endpoints to admin-service:

  • GET /api/feature-maintenance/public
  • GET /api/admin/feature-maintenance
  • PATCH /api/admin/feature-maintenance/{key}

The public status route intentionally stays unauthenticated. Admin list/update use the gateway auth adapter and global ADMIN role check so admin-service receives X-User-* actor headers. The rehearsal does not require organization at the gateway because the legacy controller is guarded by global admin role, not tenant membership. Broad /api/feature-maintenance, broad /api/admin, AI classification job reads, admin operations/audit routes, users, AI settings, wallet/support/dashboard routes, broad /api/questions, and fallback remain legacy-proxied. Rollback is returning GATEWAY_ROUTE_TABLE to deploy/gateway/routes.json or deleting the non-default feature-maintenance route entries.

Run make test-feature-maintenance-routes to statically validate the default and native feature-maintenance route tables before any live rehearsal.

Admin audit route rehearsal:

json
{
  "name": "admin-operations-audit",
  "prefix": "/api/admin/operations/audit",
  "methods": ["GET"],
  "exact": true,
  "state": "native_read",
  "target": "http://admin-service:8080",
  "target_prefix": "/v1/admin/audit-logs",
  "require_auth": true,
  "required_roles": ["ADMIN"]
}

The Compose-facing admin audit rehearsal table is deploy/gateway/routes.admin-audit-native-example.json; the local go run variant is deploy/gateway/routes.admin-audit-native-localhost-example.json. It routes only GET /api/admin/operations/audit to admin-service.

The route uses the gateway auth adapter and global ADMIN role check, but it does not require organization at the gateway because the legacy operations controller is global-admin guarded. Caller-provided organizationId query or X-Organization-Id header remains available to the native audit list as a filter during rehearsal. Admin operations health, queues, retry/clear commands, metrics, import jobs, outbox, AI settings, feature-maintenance routes not included in this table, broad /api/admin, and fallback remain legacy-proxied. Rollback is returning GATEWAY_ROUTE_TABLE to deploy/gateway/routes.json or deleting the non-default admin audit route entry.

Run make test-admin-audit-routes to statically validate the default and native admin audit route tables before any live rehearsal.

Analytics read route rehearsal:

json
{
  "name": "analytics-results",
  "prefix": "/api/analytics/results",
  "methods": ["GET"],
  "exact": true,
  "state": "native_read",
  "target": "http://analytics-service:8080",
  "target_prefix": "/v1/analytics/results",
  "require_auth": true,
  "required_roles": ["ADMIN", "TEACHER"],
  "require_organization": true
}

The Compose-facing analytics rehearsal table is deploy/gateway/routes.analytics-read-native-example.json; the local go run variant is deploy/gateway/routes.analytics-read-native-localhost-example.json. It routes only the two teacher-facing analytics reads to analytics-service:

  • GET /api/analytics/results
  • GET /api/analytics/weak-topics

Both routes use the gateway auth adapter, global ADMIN/TEACHER role checks, and organization header injection to match the legacy JwtAuthGuard, TenantGuard, and role decorators. Analytics snapshot/event writes, exam/classroom/student/parent analytics, classroom export, broad /api/analytics, broad /api/exams, broad /api/classrooms, broad /api/students, broad /api/parents, and fallback remain legacy-proxied. Those routes need summary path adapters, parent-child access checks, report export formatting, result visibility, and cross-service hydration before cutover. Rollback is returning GATEWAY_ROUTE_TABLE to deploy/gateway/routes.json or deleting the non-default analytics route entries.

Run make test-analytics-routes to statically validate the default and native analytics route tables before any live rehearsal.

Storage migration example:

json
{
  "name": "storage",
  "prefix": "/api/storage",
  "state": "native_write",
  "target": "http://document-service:8080",
  "target_prefix": "/v1/storage"
}

The full storage route-table example lives at deploy/gateway/routes.storage-native-example.json. It rewrites frontend calls such as /api/storage/presigned-upload to document-service path /v1/storage/presigned-upload without frontend changes.

Question type migration example:

json
{
  "name": "question-types-read",
  "prefix": "/api/question-types",
  "methods": ["GET"],
  "exact": true,
  "state": "shadow_read",
  "target": "http://question-bank-service:8080",
  "target_prefix": "/v1/question-types"
}

The Compose-facing question-type shadow route-table example lives at deploy/gateway/routes.question-types-shadow-example.json; the local go run variant is deploy/gateway/routes.question-types-shadow-localhost-example.json. The native rehearsal variants are deploy/gateway/routes.question-types-native-example.json and deploy/gateway/routes.question-types-native-localhost-example.json. Shadow examples match only exact GET /api/question-types; native examples match exact GET plus POST/PATCH/DELETE write methods.

The native rehearsal variants also include:

json
{
  "name": "question-types-write",
  "prefix": "/api/question-types",
  "methods": ["POST", "PATCH", "DELETE"],
  "state": "native_write",
  "target": "http://question-bank-service:8080",
  "target_prefix": "/v1/question-types",
  "require_auth": true,
  "required_roles": ["ADMIN", "TEACHER"],
  "require_organization": true
}

This non-default write route covers POST /api/question-types, PATCH /api/question-types/:id, DELETE /api/question-types/:id, PATCH /api/question-types/bulk-status, and POST /api/question-types/bulk-delete. The shadow examples remain GET-only, and the default route table stays legacy until browser parity and rollback evidence are complete.

Run make test-question-types-routes to statically validate the default, shadow, and native question-type route tables.

Run QUESTION_TYPES_SELF_TEST=1 make test-question-types-live for the hermetic live-smoke self-test. Against a running gateway with the non-default native question-type route table, run:

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 verifies /v1/routes, then exercises create/update/bulk archive/bulk delete through /api/question-types and checks the gateway route headers. It does not run during normal validation.

Attempt route rehearsal:

json
{
  "name": "exam-attempt-start",
  "prefix": "/api/exams",
  "methods": ["POST"],
  "suffix_segments": 2,
  "suffix": "/start",
  "state": "native_write",
  "target": "http://attempt-service:8080",
  "target_prefix": "/v1/exams",
  "require_auth": true,
  "required_roles": ["STUDENT"],
  "require_organization": true
}

The Compose-facing attempt rehearsal table is deploy/gateway/routes.attempt-native-example.json; the local go run variant is deploy/gateway/routes.attempt-native-localhost-example.json. It routes legacy-compatible attempt runtime paths to attempt-service:

  • POST /api/exams/:examId/start
  • GET /api/attempts/:attemptId
  • POST /api/attempts/:attemptId/answers
  • POST /api/attempts/:attemptId/submit
  • POST /api/attempts/:attemptId/events
  • GET /api/attempts/:attemptId/events
  • GET /api/attempts/:attemptId/result

POST /api/exams/:examId/start rewrites to the attempt-service compatibility alias /v1/exams/:examId/start, which delegates to the native start behavior. The broader /api/exams and /api/attempts routes remain legacy-proxied so exam authoring, publish, analytics, review-like attempt siblings, and unrelated exam routes do not move with this rehearsal. Rollback is returning GATEWAY_ROUTE_TABLE to deploy/gateway/routes.json or deleting the non-default attempt route entries.

Run make test-attempt-routes to statically validate the default and native attempt route tables before any live exam-room rehearsal.

Question list read shadow example:

json
{
  "name": "questions-list",
  "prefix": "/api/questions",
  "methods": ["GET"],
  "exact": true,
  "state": "shadow_read",
  "target": "http://question-bank-service:8080",
  "target_prefix": "/v1/legacy/questions"
}

The Compose-facing question-list route-table example lives at deploy/gateway/routes.questions-read-shadow-example.json; the local go run variant is deploy/gateway/routes.questions-read-shadow-localhost-example.json. Shadow reads target the compatibility adapter at /v1/legacy/questions so the best-effort native request exercises the same legacy-shaped projection planned for native-read cutover. Keep the broader /api/questions prefix on legacy_proxy so create/update/delete routes, AI classification subroutes, and question folders/groups continue to hit legacy. Rollback is setting the exact questions-list route to legacy_proxy or deleting it from the route table.

Question detail/editor read shadow example:

json
{
  "name": "questions-detail",
  "prefix": "/api/questions",
  "methods": ["GET"],
  "suffix_segments": 1,
  "state": "shadow_read",
  "target": "http://question-bank-service:8080",
  "target_prefix": "/v1/legacy/questions"
}

This shadows legacy GET /api/questions/:id and keeps ?view=editor unchanged for the adapter detail endpoint. The one-segment suffix matcher is required because /api/questions also owns nested legacy subroutes such as AI classification jobs. Rollback is setting questions-detail to legacy_proxy or deleting it from the route table.

Question read native rehearsal:

json
{
  "name": "questions-list",
  "prefix": "/api/questions",
  "methods": ["GET"],
  "exact": true,
  "state": "native_read",
  "target": "http://question-bank-service:8080",
  "target_prefix": "/v1/legacy/questions",
  "require_auth": true,
  "required_roles": ["ADMIN", "TEACHER"],
  "require_organization": true
}

The Compose-facing native rehearsal route table is deploy/gateway/routes.questions-read-native-example.json; the local go run variant is deploy/gateway/routes.questions-read-native-localhost-example.json. It routes only GET /api/questions and one-segment GET /api/questions/:id to the compatibility adapter in question-bank-service, forwards gateway-derived X-User-* and X-Organization-Id headers, and keeps writes plus nested /api/questions/* subroutes on legacy. Rollback is changing GATEWAY_ROUTE_TABLE back to deploy/gateway/routes.json or setting questions-list and questions-detail back to legacy_proxy.

Run make test-question-read-routes to statically validate the default, shadow, and native question-read route-table examples before live parity runs.

Run make test-question-read-native with a real access token, matching user/org headers, question-bank-service, and the native route table active to byte-compare gateway native responses with direct /v1/legacy/questions adapter responses.

Run make test-question-read-browser with a real browser session after starting the web app with NEXT_PUBLIC_API_URL pointing at the gateway to verify /teacher/questions observes the questions-list native-read header. Set QUESTION_READ_BROWSER_DETAIL_ID=auto when the native read model has at least one question and editor-detail browser evidence is required. Run make test-question-read-browser-admin with an admin token to verify /admin/questions sends allOrgs=1 through the same native-read route.

AI classification apply adapter example:

json
{
  "name": "question-classification-apply",
  "prefix": "/api/questions/ai-classify/apply",
  "methods": ["PATCH"],
  "exact": true,
  "state": "native_write",
  "target": "http://question-bank-service:8080",
  "target_prefix": "/v1/questions/ai-classify/apply",
  "require_auth": true,
  "required_roles": ["ADMIN", "TEACHER"],
  "require_organization": true
}

This carves out only legacy-compatible PATCH /api/questions/ai-classify/apply and rewrites it to PATCH /v1/questions/ai-classify/apply on question-bank-service. The exact matcher is required so /api/questions/ai-classify/apply/*, job creation/status, suggestions, and SSE classification routes remain on the broader /api/questions legacy proxy route. The example opts into gateway auth header forwarding because the native apply endpoint uses service-owned question scope and audit metadata.

The Compose-facing apply example lives at deploy/gateway/routes.question-classification-apply-native-example.json; the local go run variant is deploy/gateway/routes.question-classification-apply-native-localhost-example.json. Rollback is returning the gateway to the default route table or deleting this single route-table entry.

Static route-table coverage is available with:

bash
make test-question-classification-apply-routes

The coverage validates that the default route table has no active native apply carve-out, the native examples route only exact PATCH /api/questions/ai-classify/apply to question-bank-service with auth/org/role guards, broad /api/questions plus fallback remain legacy-proxied, and the apply route is ordered before broad legacy routes.

AI classification job route guard:

The native ai-classifier-service job foundation remains internal-only while BullMQ worker parity, taxonomy-rich prompt context, gateway RBAC, and browser parity are still deferred. Public POST /api/questions/ai-classify/suggestions, /api/questions/ai-classify/jobs*, job SSE, job errors, and cancel routes must therefore stay on the broad legacy questions route. Run make test-ai-classification-job-route-guard to scan default and non-default route-table examples and fail if any active public route targets /v1/ai-classifier* or promotes AI classification job/suggestion prefixes away from legacy. The guard allows only the exact apply carve-out above because that route targets question-bank-service.

An opt-in live smoke for this non-default write route is available with:

bash
QUESTION_CLASSIFICATION_APPLY_CONFIRM=apply-native \
QUESTION_CLASSIFICATION_APPLY_QUESTION_ID=<native-question-id> \
QUESTION_CLASSIFICATION_APPLY_AUTHORIZATION='Bearer <token>' \
QUESTION_CLASSIFICATION_APPLY_ORGANIZATION_ID=<org-id> \
make test-question-classification-apply-live

The smoke validates /v1/routes, checks GET /api/questions/ai-classify/jobs?limit=1 still stays on the broad legacy questions route, patches the apply request through the gateway, asserts the question-classification-apply route headers, and verifies the legacy-shaped apply result counts include a successful result for the requested question.

Import approval adapter example:

json
{
  "name": "exam-import-approval",
  "prefix": "/api/exam-import/jobs",
  "methods": ["POST"],
  "suffix_segments": 2,
  "suffix": "/approve",
  "state": "native_write",
  "target": "http://docx-import-service:8080",
  "target_prefix": "/v1/import/docx/jobs",
  "require_auth": true,
  "required_roles": ["ADMIN", "TEACHER"],
  "require_organization": true
}

This carves out only legacy-compatible POST /api/exam-import/jobs/{id}/approve and rewrites it to docx-import-service path /v1/import/docx/jobs/{id}/approve. Other /api/exam-import/jobs/* actions, including review save, stop, delete, formula conversion, status, and detail, remain on the broader legacy proxy route. The example is intentionally not in the default route table; the non-default route example opts into the gateway auth/header adapter, while public cutover still waits for browser review verification and full approval parity.

The Compose-facing import approval example lives at deploy/gateway/routes.import-approval-native-example.json; the local go run variant is deploy/gateway/routes.import-approval-native-localhost-example.json. Run make test-import-approval-routes to statically verify the default table keeps approval on legacy and the non-default examples carve out only POST /api/exam-import/jobs/{id}/approve.

Run the live smoke only against a gateway started with one of the non-default approval route tables and native dependencies:

bash
cd go-platform
IMPORT_APPROVAL_LIVE_CONFIRM=approve-native \
IMPORT_APPROVAL_JOB_ID=<completed-job-id> \
IMPORT_APPROVAL_AUTHORIZATION='Bearer <token>' \
IMPORT_APPROVAL_ORGANIZATION_ID=<org-id> \
make test-import-approval-live

The live smoke checks /v1/routes, verifies sibling job detail still routes to the broad legacy exam-import route, then posts approval through exam-import-approval and validates the native approval result shape. It is intentionally opt-in because it writes approved questions into native question-bank and may create draft exam snapshots when the request target is EXAM_DRAFT.

Import review-save adapter example:

json
{
  "name": "exam-import-review-save",
  "prefix": "/api/exam-import/jobs",
  "methods": ["PATCH"],
  "suffix_segments": 2,
  "suffix": "/review",
  "state": "native_write",
  "target": "http://docx-import-service:8080",
  "target_prefix": "/v1/import/docx/jobs",
  "require_auth": true,
  "required_roles": ["ADMIN", "TEACHER"],
  "require_organization": true
}

This carves out only legacy-compatible PATCH /api/exam-import/jobs/{id}/review and rewrites it to PATCH /v1/import/docx/jobs/{id}/review. It preserves reviewed parse payloads verbatim so manual question-type override metadata can survive reload. Source-text reparse and native materialized DOCX Fast token acknowledgement are supported by the native review endpoint. Non-materialized legacy temp-draft store materialization remains on legacy. Rollback is deleting this route or setting it to legacy_proxy.

The Compose-facing review-save example lives at deploy/gateway/routes.import-review-native-example.json; the local go run variant is deploy/gateway/routes.import-review-native-localhost-example.json.

Import detail reload adapter example:

json
{
  "name": "exam-import-job-detail",
  "prefix": "/api/exam-import/jobs",
  "methods": ["GET"],
  "suffix_segments": 1,
  "state": "native_read",
  "target": "http://docx-import-service:8080",
  "target_prefix": "/v1/import/docx/jobs",
  "require_auth": true,
  "required_roles": ["ADMIN", "TEACHER"],
  "require_organization": true
}

This carves out only GET /api/exam-import/jobs/{id} and rewrites it to GET /v1/import/docx/jobs/{id}. The native response includes parseResultJson from the saved reviewed payload when present, so reload can keep manual question-type overrides. Sibling paths such as /status, /review, and /approve stay on legacy unless their own route-table entries are enabled.

The Compose-facing detail example lives at deploy/gateway/routes.import-detail-native-example.json; the local go run variant is deploy/gateway/routes.import-detail-native-localhost-example.json.

Combined import review save/detail smoke example:

json
{
  "routes": [
    {
      "name": "exam-import-review-save",
      "prefix": "/api/exam-import/jobs",
      "methods": ["PATCH"],
      "suffix_segments": 2,
      "suffix": "/review",
      "state": "native_write",
      "target": "http://docx-import-service:8080",
      "target_prefix": "/v1/import/docx/jobs",
      "require_auth": true,
      "required_roles": ["ADMIN", "TEACHER"],
      "require_organization": true
    },
    {
      "name": "exam-import-job-detail",
      "prefix": "/api/exam-import/jobs",
      "methods": ["GET"],
      "suffix_segments": 1,
      "state": "native_read",
      "target": "http://docx-import-service:8080",
      "target_prefix": "/v1/import/docx/jobs",
      "require_auth": true,
      "required_roles": ["ADMIN", "TEACHER"],
      "require_organization": true
    }
  ]
}

Use the combined example only when preparing save-then-reload smoke or browser verification for P4-003. It keeps /api/exam-import/jobs/{id}/status, /approve, /stop, conversion routes, and unrelated import paths on legacy via the broader /api/exam-import proxy. Rollback is returning the gateway to the default route table or removing the two native entries.

The Compose-facing combined route table lives at deploy/gateway/routes.import-review-roundtrip-native-example.json; the local go run variant lives at deploy/gateway/routes.import-review-roundtrip-native-localhost-example.json.

DOCX Fast create-job adapter example:

json
{
  "name": "exam-import-docx-fast-create",
  "prefix": "/api/exam-import/docx-fast-jobs",
  "methods": ["POST"],
  "exact": true,
  "state": "native_write",
  "target": "http://docx-import-service:8080",
  "target_prefix": "/v1/import/docx/docx-fast-jobs",
  "require_auth": true,
  "required_roles": ["ADMIN", "TEACHER"],
  "require_organization": true
}

This carves out only legacy-compatible POST /api/exam-import/docx-fast-jobs. The native endpoint accepts the legacy JSON body with questionStorageKey, fetches uploaded DOCX bytes through document-service, and returns the legacy success envelope expected by the frontend API helper. The Compose-facing route table lives at deploy/gateway/routes.import-create-native-example.json; the local go run variant is deploy/gateway/routes.import-create-native-localhost-example.json. Static coverage for this route table is available with make test-import-create-routes.

An opt-in live smoke for the same non-default route table is available with:

bash
IMPORT_CREATE_LIVE_CONFIRM=create-native \
IMPORT_CREATE_LIVE_STORAGE_KEY=<uploaded-docx-storage-key> \
IMPORT_CREATE_LIVE_AUTHORIZATION='Bearer <token>' \
IMPORT_CREATE_LIVE_ORGANIZATION_ID=<org-id> \
make test-import-create-live

The smoke validates /v1/routes, posts the legacy-compatible DOCX Fast create body through the gateway, asserts the exam-import-docx-fast-create route headers, verifies the legacy success envelope, and checks a sibling job detail request still stays on the broad legacy import route. It does not run during normal tests and does not promote the default route table.

Browser-route evidence for the real import surface is available with:

bash
IMPORT_CREATE_BROWSER_AUTHORIZATION='Bearer <token>' \
IMPORT_CREATE_BROWSER_ORGANIZATION_ID=<org-id> \
IMPORT_CREATE_BROWSER_DOCX_FILE=/path/to/sample.docx \
make test-import-create-browser

If the DOCX object already exists, use IMPORT_CREATE_BROWSER_STORAGE_KEY=<uploaded-docx-storage-key> instead of IMPORT_CREATE_BROWSER_DOCX_FILE. The browser smoke opens the import UI, captures the browser-observed POST /api/exam-import/docx-fast-jobs response, and verifies the same route headers and legacy envelope.

The same non-default table can also carve out the materialized DOCX Fast temp-draft read branch:

json
{
  "name": "exam-import-docx-fast-temp-draft",
  "prefix": "/api/exam-import/docx-fast-jobs",
  "methods": ["GET"],
  "suffix_segments": 2,
  "suffix": "/temp-draft",
  "state": "native_read",
  "target": "http://docx-import-service:8080",
  "target_prefix": "/v1/import/docx/docx-fast-jobs",
  "require_auth": true,
  "required_roles": ["ADMIN", "TEACHER"],
  "require_organization": true
}

Read-only live smoke coverage for this non-default route is available with:

bash
IMPORT_TEMP_DRAFT_JOB_ID=<completed-native-docx-fast-job-id> \
IMPORT_TEMP_DRAFT_AUTHORIZATION='Bearer <token>' \
IMPORT_TEMP_DRAFT_ORGANIZATION_ID=<org-id> \
make test-import-temp-draft-live

The smoke validates /v1/routes, checks a sibling temp-draft asset metadata request still stays on the broad legacy import route, reads the temp-draft through the gateway, asserts the exam-import-docx-fast-temp-draft route headers, and verifies the legacy materialized draft envelope with token materialized-{id}.

The materialized asset content adapter can be carved out independently. It matches only GET /api/exam-import/docx-fast-jobs/{id}/temp-draft/assets/{asset}/content and leaves other temp-draft asset actions on legacy:

json
{
  "name": "exam-import-docx-fast-asset-content",
  "prefix": "/api/exam-import/docx-fast-jobs",
  "methods": ["GET"],
  "suffix_segments": 5,
  "suffix": "/content",
  "state": "native_read",
  "target": "http://docx-import-service:8080",
  "target_prefix": "/v1/import/docx/docx-fast-jobs",
  "require_auth": true,
  "required_roles": ["ADMIN", "TEACHER"],
  "require_organization": true
}

Read-only live smoke coverage for this binary route is available with:

bash
IMPORT_TEMP_ASSET_JOB_ID=<completed-native-docx-fast-job-id> \
IMPORT_TEMP_ASSET_ID=<materialized-temp-asset-id> \
IMPORT_TEMP_ASSET_AUTHORIZATION='Bearer <token>' \
IMPORT_TEMP_ASSET_ORGANIZATION_ID=<org-id> \
make test-import-temp-asset-live

The smoke validates /v1/routes, checks a sibling temp-draft asset metadata request still stays on the broad legacy import route, streams the asset content through the gateway, asserts the exam-import-docx-fast-asset-content route headers, and verifies the binary response has a non-empty body, Content-Type, and private cache header.

It can also carve out DOCX Fast reprocess while preserving the same job id:

json
{
  "name": "exam-import-docx-fast-reprocess",
  "prefix": "/api/exam-import/docx-fast-jobs",
  "methods": ["POST"],
  "suffix_segments": 2,
  "suffix": "/reprocess",
  "state": "native_write",
  "target": "http://docx-import-service:8080",
  "target_prefix": "/v1/import/docx/docx-fast-jobs",
  "require_auth": true,
  "required_roles": ["ADMIN", "TEACHER"],
  "require_organization": true
}

An opt-in live smoke for this non-default write route is available with:

bash
IMPORT_REPROCESS_CONFIRM=reprocess-native \
IMPORT_REPROCESS_JOB_ID=<native-docx-fast-job-id> \
IMPORT_REPROCESS_AUTHORIZATION='Bearer <token>' \
IMPORT_REPROCESS_ORGANIZATION_ID=<org-id> \
make test-import-reprocess-live

The smoke validates /v1/routes, asserts the sibling job detail route stays on the broad legacy import route, posts the reprocess request through the gateway, checks the exam-import-docx-fast-reprocess route headers, and verifies the legacy success envelope keeps the same job id and message DOCX Fast import reprocessed.

Import status/history adapter examples:

json
{
  "name": "exam-import-teacher-library",
  "prefix": "/api/exam-import/teacher-library",
  "methods": ["GET"],
  "exact": true,
  "state": "native_read",
  "target": "http://docx-import-service:8080",
  "target_prefix": "/v1/import/docx/teacher-library",
  "require_auth": true,
  "require_organization": true
}
json
{
  "name": "exam-import-job-status",
  "prefix": "/api/exam-import/jobs",
  "methods": ["GET"],
  "suffix_segments": 2,
  "suffix": "/status",
  "state": "native_read",
  "target": "http://docx-import-service:8080",
  "target_prefix": "/v1/import/docx/jobs",
  "require_auth": true,
  "require_organization": true
}

The status/history example also includes exact routing for GET /api/exam-import/algorithm-jobs/events and the older GET /api/imports/{jobId}/status alias. Broader /api/exam-import* and /api/imports* traffic remains on legacy so review save, detail, delete, conversion, and other sibling actions are not captured. The Compose-facing route table lives at deploy/gateway/routes.import-status-native-example.json; the local go run variant is deploy/gateway/routes.import-status-native-localhost-example.json. These non-default examples opt into gateway auth/header injection so native status handlers receive the current user and organization headers. Static coverage for these route tables is available with make test-import-status-routes. For BullMQ bridge rehearsals, make test-docx-bullmq-status fetches GET /api/exam-import/teacher-library?q=<jobId> through this route table and asserts the matching row includes the legacy-shaped queue object. It is a read-only live smoke and does not promote the default route table. Browser route evidence uses make test-docx-import-library-browser, which opens the real /teacher/exams/library page and verifies the browser-observed route headers for the same teacher-library carve-out.

Shadow Read

For GET and HEAD routes in shadow_read, the gateway returns the legacy response and sends a best-effort request to the native target. It logs status matches/diffs without changing the client response.

Go-platform documentation is generated from repository Markdown.