API: POST /walker/class
POST /walker/class
Completes the onboarding class-pick flow. The Walker is auto-created with classId=null at /auth/callback time (ADR-0006 §Mock auth detail); this endpoint sets the class so the mobile client can skip the class-pick screen on subsequent launches.
Status: draft contract — implementation in sub-phase 13-3 (2026-05-20). Related: D-016 (progression spec, ratified 2026-05-20), ADR-0006 (mock-auth Walker auto-create), ADR-0007 (Android network layer + DTO contract), GET /walker/profile, POST /auth/callback.
Endpoint
| Field | Value |
|---|---|
| Method | POST |
| Path | /walker/class |
| Auth | Internal session JWT (Bearer) |
| Idempotent | Yes (re-submitting the same classId is a no-op) |
| Rate limit | 12 req/min/walker (onboarding flow only) |
Design choice — why mutate, not create
Two shapes were considered for class-pick:
- Option (a):
/auth/callbackcreates onlyUser(not Walker). ThenPOST /walker/create { classId }creates the Walker. - Option (b) — RATIFIED:
/auth/callbackcontinues to auto-createWalkerwithclassId=null(per ADR-0006 §5.a-5.d).POST /walker/class { classId }sets the class on the existing Walker.
Option (b) chosen because:
- ADR-0006 §“Mock auth detail” Server-flow step 1 is load-bearing: “Look up
Userbyemail. If missing, create User + Walker + StreakState + FactionRep rows exactly as the production callback does (sections 5.a-5.d ofauth-callback.mdx).” Switching to option (a) would mean re-authoring the production/auth/callbackcontract too — ADR-0006’s whole point is that the contract is the migration vehicle. - ADR-0007 §2 ships a Kotlin
WalkerSummaryDTO that the mobile client already deserializes from/auth/callback. Option (a) would null this out at register time, breaking the contract. - ADR-0006 also creates
StreakStateand 5xFactionReprows in the same transaction. Splitting Walker creation off would either duplicate those rows in the new endpoint, or leave them dangling without a Walker FK target. Option (b) avoids both.
The mobile flow per option (b):
sign in -> /auth/callback (returns walker.classId=null) -> mobile renders class-pick screen -> user picks Cartographer -> POST /walker/class { classId: "class.cartographer" } -> returns full WalkerProfile (same shape as GET /walker/profile) -> mobile lands on home screenSubsequent app launches: GET /walker/profile returns classId != null, mobile skips the class-pick screen.
Request
Headers
| Header | Required | Notes |
|---|---|---|
Authorization | yes | Bearer <internal-session-JWT> |
Content-Type | yes | application/json |
X-Request-Id | optional | UUID v4. Server echoes in logs and error envelope. Per ADR-0007 §6 client-generated. |
Body
{ "classId": "class.cartographer"}| Field | Type | Required | Notes |
|---|---|---|---|
classId | string | yes | References data/src/content/classes/ by id. Server validates against the data layer; unknown class ids return 422 INVALID_CLASS_ID. |
Response
200 OK
Returns the full WalkerProfileResponse (same shape as GET /walker/profile). The mobile client lands on the home screen in a single round-trip.
{ "walker": { "id": "01HM4...", "displayName": "Wanderer-9f3a2c", "level": 0, "classId": "class.cartographer", "totalLifetimeSteps": 0, "treePointsBanked": 0, "treePointsSpent": 0, "currentRegionId": "region.plenny", "createdAt": "2026-05-20T08:00:00Z", "lastActiveAt": "2026-05-20T08:14:22Z" }, "region": { /* ... */ }, "streak": { /* ... */ }, "activeQuests": [], "factionRanks": [ /* 5 entries, neutral tier 0 */ ], "subscription": { "tier": "none", "validUntil": null }, "flags": { "isFirstLogin": false, "hasPendingDelete": false, "isInQuarantine": false, "appUpgradeAvailable": null }}Idempotent re-pick
If the walker already has the requested classId set, the server returns 200 with the current profile and does NOT touch the DB. Safe to call from a class-pick screen on every mount.
400 Bad Request
{ "error": "VALIDATION_ERROR", "message": "Request body failed schema validation.", "details": { /* Zod flatten output */ }}Body did not parse against SetClassRequestSchema (e.g. missing classId, wrong type, empty string).
401 Unauthorized
Session JWT invalid or missing.
404 Not Found
{ "error": "WALKER_NOT_FOUND", "message": "Walker not found for the authenticated user."}Defensive — should be impossible under ADR-0006 because /auth/callback lazy-creates the Walker. Surfaced for forward-compatibility with the production-mode auth path where Walker creation is delegated to a separate worker.
409 Conflict
{ "error": "CLASS_ALREADY_SET", "message": "Walker already has class 'class.X'; cannot change to 'class.Y'.", "details": { "currentClassId": "class.X", "requestedClassId": "class.Y" }}Walker already has a different non-null classId. Class re-pick (respec) is out of Phase 13 scope; a future “respec class” feature is tracked as a B-level decision for post-vertical-slice.
422 Unprocessable Entity
{ "error": "INVALID_CLASS_ID", "message": "Class 'class.nonexistent-xyz' is not in the data layer.", "details": { "classId": "class.nonexistent-xyz", "knownClassIds": ["class.cartographer"] }}classId is not in data/src/content/classes/. knownClassIds returns the current authored set so the mobile client can pin against drift between the bundled class data and the server-side data layer.
Class id validation
Server-side validation against backend/src/common/game-content.ts classes array (the backend’s runtime adapter for data/src/content/classes/). The adapter is maintained in sync with the data layer manually until the ESM/CJS bundler resolution lands (see common/game-content.ts header). Phase 13-3 ships the Cartographer; subsequent classes append to both the data layer instance set AND the backend adapter.
Telemetry
info-level log with: walkerId, classId, idempotent (true if walker already had this classId), latencyMs. No PII.
Open follow-ups
- Class re-pick (respec) feature. Post-Phase-13 product question. If ratified, this endpoint accepts a
force: trueflag and returns 200 even when classId is already set. Currently 409. - Faction-allegiance side effect. Classes may eventually carry a starter faction allegiance (e.g. Cartographer → faction.unfinished-guild +20 starting reputation). Phase 13-3 does NOT mutate
FactionRepon class-pick — all 5 factions stay at tier 0 / rep 0 from/auth/callback. A future B-level decision wires class-bound starter rep. currentRegionIdretention. The walker’scurrentRegionIdis set toregion.plennyat/auth/callback.class.startRegionId(class.cartographer.startRegionId = "region.plenny") is informational only; the endpoint does NOT updatecurrentRegionIdbased on class. If a future non-Plenny-starter class is authored, this contract amends to setcurrentRegionId = class.startRegionIdon first class-pick.