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
  • exam_start_adapter
  • organization_membership_adapter
  • 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. segment_constraints is optional and constrains a zero-based suffix segment, for example {"1": "members"} on /api/organizations/{organizationId}/members/{accountId} so unit deletes still fall through to legacy. target_prefix rewrites the public legacy-compatible path to the native service path. If it is omitted, the original path is forwarded unchanged. exam_target and attempt_target are only used by the exam_start_adapter state so the gateway can call exam-service runtime access before forwarding data.startInput to attempt-service.

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 for HS256 local tokens or IAM_JWKS_URL/GATEWAY_JWKS_URL for IAM RS256 tokens, 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. JWKS keys are cached briefly and refreshed on key miss; HS256 fallback remains available for local legacy-token rehearsal. 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.

Teacher Word Print Rehearsal

deploy/gateway/routes.teacher-print-export-bff-native-example.json and its localhost variant map only these authenticated teacher-compatible paths to bff-service:

  • GET/POST /api/teacher/exam-print-templates
  • PATCH /api/teacher/exam-print-templates/{templateId}
  • GET/POST /api/teacher/exams/{examId}/print-exports
  • GET /api/teacher/exams/{examId}/print-exports/{exportId}/content

Every route requires a valid gateway bearer token, an ADMIN or TEACHER role, and an organization id. Gateway replaces any caller-supplied actor headers with authenticated X-User-* scope before forwarding to BFF. The default deploy/gateway/routes.json keeps these paths on the broad legacy teacher route. Run make test-teacher-print-export-bff-routes and the focused Gateway test before a live rehearsal; no default promotion or browser proof is claimed by this route table alone.

The bounded SVC-001-default-route-proof-bundle is recorded in docs/qa/api-gateway-default-route-proof-bundle.md. It indexes gateway route diff, live/browser evidence, and rollback notes without promoting deploy/gateway/routes.json.

Teacher Exam Blueprint Rehearsal

deploy/gateway/routes.teacher-exam-blueprint-bff-native-example.json and its localhost variant map only the following authenticated teacher-compatible paths to bff-service:

  • GET/POST /api/teacher/exam-blueprints
  • GET/PATCH/DELETE /api/teacher/exam-blueprints/{blueprintId}

Every route requires a gateway token, an ADMIN or TEACHER role, and an organization id. Gateway replaces caller actor headers before forwarding to BFF. Run make test-teacher-exam-blueprint-bff-routes; this is static and hermetic proof only, not default-route or browser promotion.

Gateway Route Audit Projection

Set AUDIT_SERVICE_URL or AUDIT_SERVICE_BASE_URL to enable optional best-effort route-operation audit projection. When enabled, api-gateway posts one client-facing matched-route event to audit-service POST /v1/audit-events after the final route status is known. Missing or failing audit-service calls are logged and do not change the gateway response.

Event shape:

  • sourceService=api-gateway
  • sourceEventId=gateway:route:<requestId-or-correlationId>
  • category=OPERATIONS
  • action=gateway.route.request
  • entity=GatewayRoute
  • entityId=<route.name>
  • outcome=SUCCESS|DENIED|FAILED|ERROR
  • retentionPolicy=gateway-route-operations
  • metadataSchemaVersion=1

Metadata is intentionally redacted. It includes route name/state/prefix, HTTP method, public path, query parameter keys only, target kind/service, auth requirements, required roles, and final HTTP status. It must not include authorization headers, cookies, raw request bodies, or query values.

Auth session route rehearsal:

json
{
  "name": "auth-login",
  "prefix": "/api/auth/login",
  "methods": ["POST"],
  "exact": true,
  "state": "native_write",
  "target": "http://iam-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 explicit IAM native auth parity paths to iam-service:

  • POST /api/auth/register
  • POST /api/auth/login
  • POST /api/auth/refresh
  • POST /api/auth/logout
  • GET /api/auth/me
  • POST /api/auth/me/password
  • POST /api/auth/forgot-password
  • POST /api/auth/reset-password
  • GET /api/auth/google/config
  • POST /api/auth/google
  • GET /api/admin/auth-settings/google
  • PATCH /api/admin/auth-settings/google

Public auth operations intentionally omit the gateway auth adapter because iam-service directly handles login/register and refresh/reset-token bodies. GET /api/auth/me and POST /api/auth/me/password require a valid access JWT at the Gateway and are validated again by IAM. The admin Google settings routes keep Gateway ADMIN authorization in front of the IAM service-local guard. The default route table promotes the listed exact IAM routes. Every other /api/auth path, wrong method, and trailing-slash variant is fail-closed as auth/removed with HTTP 410; it can never reach a legacy or generic /api fallback. The broader /api/admin route remains legacy-proxied for operations outside this auth cutover. Rollback may replace an exact native route with an explicit removed route, but must not restore legacy Auth routing.

Gateway browser origins are an explicit allowlist. Local and test environments default to the frontend origins on localhost/127.0.0.1 ports 3000, 5173, and 5201 through 5206. Set GATEWAY_CORS_ALLOWED_ORIGINS to a comma-separated list of exact http:// or https:// origins in other environments; arbitrary origins and wildcard credentialed CORS are rejected before any upstream call.

POST /api/auth/register, /login, /forgot-password, /reset-password, and /google use a bounded, per-Gateway-instance fixed-window limiter keyed by the resolved client address and normalized path. Configure the positive request count with GATEWAY_AUTH_RATE_LIMIT_MAX (default 10) and the positive duration with GATEWAY_AUTH_RATE_LIMIT_WINDOW (default 1m). Rejected requests return 429, Retry-After, rate-limit metadata, the matched route headers, and the existing Gateway request ID. GATEWAY_TRUSTED_PROXY_CIDRS must list ingress/proxy CIDRs before the Gateway will honor X-Forwarded-For or X-Real-IP; untrusted peers cannot spoof those headers. A shared external limiter remains a deployment concern when running multiple Gateway replicas.

Before promoting admin IAM routes, keep IAM_JWKS_URL pointed at the IAM JWKS endpoint when IAM signs RS256 tokens. Local HS256 rehearsal remains available through AUTH_JWT_SECRET.

Run make test-auth-routes to statically validate the default and native auth route tables before any browser session rehearsal. That target also runs the hermetic self-tests for the opt-in live and browser smokes.

Read-only live smoke coverage for the same non-default auth route table is available with:

bash
AUTH_ROUTE_LIVE_CONFIRM=auth-native \
GATEWAY_BASE_URL=http://localhost:8085 \
make test-auth-routes-live

The smoke validates /v1/routes, calls GET /api/auth/google/config through the IAM native route, verifies unauthenticated GET /api/auth/me is routed to IAM and rejected with AUTH_UNAUTHORIZED, and checks /api/auth/me/profile remains on the broad legacy auth route. It does not run during normal validation and does not promote the default route table.

Browser-originated proof for the same non-default route table is available with:

bash
AUTH_ROUTE_BROWSER_CONFIRM=auth-native \
WEB_BASE_URL=http://localhost:3000 \
GATEWAY_BASE_URL=http://localhost:8085 \
make test-auth-routes-browser

Start the web app with VITE_API_BASE_URL pointing at the same gateway before running the browser smoke. It opens /auth/login, clicks the Google Auth entry, asserts the browser-observed GET /api/auth/google/config route headers, then fetches unauthenticated /api/auth/me from the page context and verifies the IAM AUTH_UNAUTHORIZED envelope. The legacy sibling route guard remains enabled by default. Artifacts are written under output/playwright/auth-route-browser-smoke-*.

Profile route rehearsal:

json
{
  "name": "profile-base-update",
  "prefix": "/api/auth/me/profile",
  "methods": ["PATCH"],
  "exact": true,
  "state": "native_write",
  "target": "http://profile-service:8080",
  "target_prefix": "/v1/users/{userId}/profile",
  "require_auth": true,
  "required_roles": ["ADMIN", "STAFF", "TEACHER", "STUDENT", "PARENT"]
}

The Compose-facing profile rehearsal table is deploy/gateway/routes.profile-native-example.json; the local go run variant is deploy/gateway/routes.profile-native-localhost-example.json. It routes only current-account profile update paths to profile-service:

  • PATCH /api/auth/me/profile
  • PATCH /api/auth/me/teacher-profile
  • PATCH /api/auth/me/student-profile
  • PATCH /api/auth/me/parent-profile

The {userId} placeholder in target_prefix is resolved from the verified gateway JWT principal before proxying. These routes require gateway auth and role checks, but they do not require organization context.

Teacher self-service KYC has a separate non-default rehearsal table: deploy/gateway/routes.profile-kyc-native-example.json; the local go run variant is deploy/gateway/routes.profile-kyc-native-localhost-example.json. It routes only exact GET /api/auth/me/kyc/teacher and POST /api/auth/me/kyc/teacher calls to /v1/users/{userId}/teacher-kyc, requires a TEACHER gateway principal, and does not require organization context. The route table keeps broader /api/auth, KYC child/sibling paths, /api/admin/users/kyc/teacher*, and fallback routes legacy-proxied; admin review uses a separate rehearsal table and default browser cutover remains separate.

Admin teacher KYC review also has a separate non-default rehearsal table: deploy/gateway/routes.profile-admin-kyc-native-example.json; the local go run variant is deploy/gateway/routes.profile-admin-kyc-native-localhost-example.json. It routes only the profile-service-owned admin review paths:

  • GET /api/admin/users/kyc/teacher
  • GET /api/admin/users/kyc/teacher/{userId}
  • POST /api/admin/users/kyc/teacher/{userId}/approve
  • POST /api/admin/users/kyc/teacher/{userId}/reject

These routes require a gateway-verified ADMIN principal, do not require organization context, preserve list query parameters and review request bodies, and proxy to /v1/admin/users/kyc/teacher*. The route table keeps self-service KYC, broader /api/auth, broader /api/admin, unrelated /api/admin/users*, and fallback routes legacy-proxied so admin-service wrapping, response hydration, and default browser cutover remain separate.

The default /api/auth route remains legacy-proxied so /api/auth/me, KYC, Google auth, test-login, forgot/reset password, email-change, and any session-envelope hydration stay legacy until browser parity is proven. Rollback is returning GATEWAY_ROUTE_TABLE to deploy/gateway/routes.json or deleting the non-default profile route entries.

Run make test-profile-routes to statically validate the default, native profile update, native self-service KYC, and native admin KYC route tables before any live profile 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.

The membership-write rehearsal table is deploy/gateway/routes.organizations-membership-write-native-example.json; the local go run variant is deploy/gateway/routes.organizations-membership-write-native-localhost-example.json. It keeps the read routes above and adds only these IAM-owned membership writes:

  • POST /api/organizations/{organizationId}/members
  • DELETE /api/organizations/{organizationId}/members/{accountId}

The write routes use organization_membership_adapter. The adapter rewrites the legacy add-member body field userId to IAM's accountId, forwards the request to iam-service /v1/organizations/{organizationId}/members, and rewrites member deletes to /v1/organizations/{organizationId}/members/{accountId}. PATCH member updates, unit mutations, organization create/update/purge, student imports, the broad /api/organizations route, and fallback remain legacy-proxied. Default routes stay legacy until browser proof and rollback evidence exist.

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

When the gateway is running with the non-default membership-write route table, run the opt-in live smoke with an IAM admin bearer token and a disposable member account id:

bash
ORGANIZATION_MEMBERSHIP_WRITE_CONFIRM=write-iam-membership \
ORGANIZATION_MEMBERSHIP_WRITE_ORGANIZATION_ID=<org-id> \
ORGANIZATION_MEMBERSHIP_WRITE_USER_ID=<disposable-account-id> \
ORGANIZATION_MEMBERSHIP_WRITE_AUTHORIZATION='Bearer <iam-admin-token>' \
ORGANIZATION_MEMBERSHIP_WRITE_ARTIFACT_DIR=output/smoke/organization-membership-write \
GATEWAY_BASE_URL=http://localhost:8085 \
make test-organization-membership-write-live

The smoke asserts /v1/routes, gateway route headers for add/remove, the IAM Organization member added and Organization member removed response envelopes, and then deletes the same member it added. Cookie-only auth is not supported for this smoke because IAM admin routes read the bearer Authorization header directly.

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.

BFF admin overview route rehearsal:

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

The Compose-facing BFF rehearsal table is deploy/gateway/routes.bff-admin-overview-native-example.json; the local go run variant is deploy/gateway/routes.bff-admin-overview-native-localhost-example.json. It routes only GET /api/admin/overview to bff-service. The route uses the gateway auth adapter and global ADMIN role check, but it does not require organization at the gateway because the admin overview shell is global-admin scoped and the BFF service treats organization context as optional for this screen. POST /api/admin/overview, nested overview/export routes, admin operations, broad /api/admin, and fallback remain legacy-proxied. Rollback is returning GATEWAY_ROUTE_TABLE to deploy/gateway/routes.json or deleting the non-default BFF route entry.

Run make test-bff-routes to statically validate the default and native BFF route tables and run the focused gateway adapter behavior test 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 strict v2 storage route-table example lives at deploy/gateway/routes.storage-native-example.json. It exposes only the five owner-scoped multipart control routes, authenticated media reads, and object deletion. It has no /api fallback and deliberately does not expose the older single-PUT /api/storage/presigned-upload or standalone media-create write routes; browser writes must use the quota-accounted multipart control plane.

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": "exam_start_adapter",
  "exam_target": "http://exam-service:8080",
  "attempt_target": "http://attempt-service:8080",
  "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 the legacy-compatible start path through the gateway adapter and the remaining attempt runtime paths to attempt-service:

  • GET /api/student/attempts
  • 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

GET /api/student/attempts rewrites to /v1/student/attempts and keeps only student-auth scope; it does not require organization context because the legacy route filters by the authenticated student.

POST /api/exams/:examId/start uses the gateway exam_start_adapter route state in the non-default attempt rehearsal. The adapter calls exam-service runtime access first, returns a denial without calling attempt-service when runtime access is denied, and forwards only data.startInput to attempt-service with X-Internal-Service: api-gateway, the shared X-Internal-Token, X-Exam-Runtime-Decision-Source: exam-service, and idempotency/correlation context when runtime access is allowed. 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.

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

bash
ATTEMPT_LIVE_CONFIRM=attempt-native \
ATTEMPT_EXAM_ID=<published-exam-id> \
ATTEMPT_AUTHORIZATION='Bearer <student-token>' \
ATTEMPT_ORGANIZATION_ID=<org-id> \
make test-attempt-live

The smoke checks /v1/routes, GET /api/student/attempts, the exam_start_adapter start path, and attempt detail/events/result route headers against a running gateway. ATTEMPT_ANSWER_BODY_JSON, ATTEMPT_EVENT_BODY_JSON, and ATTEMPT_SUBMIT_CONFIRM=submit-native opt into write-path checks for a disposable attempt fixture. This target does not promote the default route table and should be paired with browser evidence before any public cutover.

The matching browser smoke uses Playwright from a real web page context and asserts gateway route headers on browser-originated fetches:

bash
ATTEMPT_BROWSER_CONFIRM=attempt-native \
ATTEMPT_BROWSER_AUTHORIZATION='Bearer <student-token>' \
ATTEMPT_BROWSER_ORGANIZATION_ID=<org-id> \
ATTEMPT_BROWSER_EXAM_ID=<published-exam-id> \
make test-attempt-browser

For read-only proof against an existing attempt, set ATTEMPT_BROWSER_SKIP_START=1 and ATTEMPT_BROWSER_ATTEMPT_ID=<attempt-id>. Artifacts are written under output/playwright/attempt-browser-smoke-*.

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.

Question editor write rehearsal uses deploy/gateway/routes.questions-write-native-example.json and deploy/gateway/routes.questions-write-native-localhost-example.json. Run make test-question-write-routes for static route validation, QUESTION_WRITE_SELF_TEST=1 make test-question-write-live for hermetic live smoke validation, and QUESTION_WRITE_BROWSER_SELF_TEST=1 make test-question-write-browser for browser-smoke preflight. Real write promotion must follow docs/qa/question-write-route-rehearsals.md and keep default routes legacy until live/browser artifacts and rollback notes exist.

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 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. The only AI preview exception is the non-default teacher rehearsal in deploy/gateway/routes.teacher-ai-preview-native-example.json and its localhost counterpart. It routes only exact authenticated POST/api/questions/ai-generation/generate and /api/questions/ai-solutions/editor requests to the corresponding /v1/ai/*/preview owner endpoints. The same isolated table also maps exact reviewed writes /api/questions/ai-preview-drafts and /api/questions/ai-preview-solutions to Question Bank; the latter appends a versioned canonical solution only after teacher review. Every route requires ADMIN/TEACHER plus organization scope; the default routes.json contains none of these active routes and broad /api/questions remains legacy-proxied.

Run make test-teacher-ai-preview-routes to verify the narrow manifest and make test-ai-classification-job-route-guard to scan all gateway tables. The guard fails any other active public AI target, job/suggestion route, or broad generation/solution prefix; it additionally validates the fixed P6-045 preview carve-outs and the existing question-bank apply carve-out.

An opt-in live legacy-route guard is available with:

bash
AI_CLASSIFICATION_JOB_LIVE_CONFIRM=legacy-job-routes \
AI_CLASSIFICATION_JOB_AUTHORIZATION='Bearer <token>' \
make test-ai-classification-job-live

This live smoke reads /v1/routes, verifies public AI job/suggestion/ generation/solution route prefixes are not promoted to native AI service paths, and checks GET /api/questions/ai-classify/jobs?limit=1 still reports the broad questions legacy_proxy gateway headers. If AI_CLASSIFICATION_JOB_ID is supplied, it also checks the job SSE route headers while allowing curl timeout after headers because the stream can stay open. It never calls cancel because cancel is a write and needs a future disposable fixture before native promotion.

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.

Teacher DOCX Fast owner-source rehearsal combines the multipart source upload, DOCX Fast create, native job-detail reload, review save, and explicit Question Bank approval in one non-default table. The browser posts the DOCX only to POST /api/teacher/import-sources, receives an opaque sourceAssetId, then posts that id to POST /api/exam-import/docx-fast-jobs. It never receives or passes an object-storage key. Compose uses deploy/gateway/routes.teacher-docx-fast-native-example.json; local go run uses deploy/gateway/routes.teacher-docx-fast-native-localhost-example.json. Run make test-teacher-docx-fast-routes before a rehearsal. The Teacher UI polls the owner job detail while it is pending and exposes the editor only after the job is completed; it requires both an authenticated Gateway session and ?teacherImportGateway=native; the editor link additionally opts into ?teacherImportReviewGateway=native. These are candidate gates only; the default route table remains unchanged and rollback is setting GATEWAY_ROUTE_TABLE=deploy/gateway/routes.json.

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.