API: POST /tree/allocate
POST /tree/allocate
Batch-allocates one or more passive-tree nodes and/or keystones for the authenticated walker. Validates entity existence, prerequisites, available points, and region accessibility — in that order, fail-fast per entry index. Executes all inserts + point decrement in a single Prisma transaction. Returns the full updated tree state (equivalent to GET /tree/state) so the client does not need a second round-trip.
Status: draft contract — implementation in phase 8b.
Related: ADR-0006 (test-phase infrastructure), GET /tree/state, data/src/schemas/node.ts, data/src/schemas/keystone.ts.
Test-phase mock mode
Phase 8b ships against the test-phase infrastructure per ADR-0006. Differences from the contract below:
- No App Check token requirement.
X-Firebase-AppCheckheader is ignored. - All allocations are non-provisional.
provisionalis alwaysfalse— the provisional/reconciled split re-engages at the production-migration phase. - Idempotency (partial). At-most-once insertion is guaranteed by the DB unique constraints on
(walkerId, nodeId)and(walkerId, keystoneId). Re-submitting an already-allocated entry returns 409ALREADY_ALLOCATEDrather than a cached 200. A durable idempotency-key table (full idempotency) is a production-phase concern.
Endpoint
| Field | Value |
|---|---|
| Method | POST |
| Path | /tree/allocate |
| Auth | Internal session JWT (Bearer) |
| Idempotent | Yes (see idempotency section) |
| Rate limit | 30 req/min/walker |
Request
Headers
| Header | Required | Notes |
|---|---|---|
Authorization | yes | Bearer <internal-session-JWT> |
X-Firebase-AppCheck | yes | Standard hardening (ignored in mock mode) |
Body
{ "allocations": [ { "type": "node", "id": "node.even-stride", "clusterId": "cluster.first-steps" }, { "type": "keystone", "id": "keystone.unshaken-step", "clusterId": "cluster.first-steps" } ], "idempotencyKey": "550e8400-e29b-41d4-a716-446655440000"}| Field | Type | Constraints | Notes |
|---|---|---|---|
allocations | array | min 1, max 50 | Ordered list of entries to allocate. |
allocations[].type | "node" or "keystone" | required | Determines which data-layer lookup is used. |
allocations[].id | string | non-empty | References data/src/content/nodes/ or data/src/content/keystones/ by string id. |
allocations[].clusterId | string | non-empty | Informational — used for client routing; server re-resolves cluster from the data layer. |
idempotencyKey | UUID v4 | required | Re-submission with the same key returns 200 if all entries are already allocated. |
Validation order (fail-fast per entry index)
For each entry in allocations, in order:
- Entity exists — id present in the data layer. → 404
NODE_NOT_FOUND/KEYSTONE_NOT_FOUND - Not already allocated — walker has no active (non-refunded) allocation for this id. → 409
ALREADY_ALLOCATED - Prerequisites met:
- Nodes: all ids in
node.requires[]must be active allocations for this walker. → 422PREREQUISITES_NOT_MET(details includemissingPrerequisiteIds) - Keystones:
keystone.unlockQuestIdmust be in walker’s completed QuestProgress. → 422PREREQUISITES_NOT_MET(details includemissingQuestId)
- Nodes: all ids in
- Region accessible —
walker.totalLifetimeSteps >= region.gatingStepsfor the entity’s cluster’s region. → 403REGION_NOT_ACCESSIBLE
After all per-entry checks pass, a single final check:
- Sufficient points —
walker.availablePoints >= sum of all entry costs(nodes:node.cost; keystones: 1). → 422INSUFFICIENT_POINTS
Response
200 OK
Same shape as GET /tree/state. Walker’s availablePoints reflects the post-allocation value.
{ "walker": { "id": "01HM4...", "availablePoints": 1, "totalLifetimeSteps": 8421 }, "allocations": { "nodes": [ { "nodeId": "node.even-stride", "clusterId": "cluster.first-steps", "allocatedAt": "2026-05-19T10:00:00Z", "provisional": false } ], "keystones": [ { "keystoneId": "keystone.unshaken-step", "clusterId": "cluster.first-steps", "allocatedAt": "2026-05-19T10:00:01Z", "provisional": false, "questUnlockSource": "quest.001-first-road" } ] }, "available": { "unlockableNodes": [], "unlockableKeystones": [] }, "topology": { "regions": [], "clusters": [], "nodes": [], "keystones": [] }}400 Bad Request
Request body failed Zod schema validation.
{ "error": "VALIDATION_ERROR", "message": "Request body failed schema validation.", "details": { "fieldErrors": { "idempotencyKey": ["idempotencyKey must be a UUID v4"] }, "formErrors": [] }}401 Unauthorized
Session JWT missing or invalid.
403 Forbidden
{ "error": "REGION_NOT_ACCESSIBLE", "message": "Walker has not reached the step threshold for region 'region.frostlands'.", "details": { "entryIndex": 0, "regionId": "region.frostlands", "gatingSteps": 100000, "walkerSteps": 8421 }}404 Not Found
{ "error": "NODE_NOT_FOUND", "message": "Node 'node.nonexistent' does not exist in the data layer.", "details": { "entryIndex": 0, "id": "node.nonexistent" }}Or KEYSTONE_NOT_FOUND with the same shape.
409 Conflict
{ "error": "ALREADY_ALLOCATED", "message": "Node 'node.even-stride' is already allocated.", "details": { "entryIndex": 0, "id": "node.even-stride" }}422 Unprocessable Entity
PREREQUISITES_NOT_MET — node:
{ "error": "PREREQUISITES_NOT_MET", "message": "Node 'node.surveyors-squint' has unmet prerequisites.", "details": { "entryIndex": 0, "id": "node.surveyors-squint", "missingPrerequisiteIds": ["node.even-stride"] }}PREREQUISITES_NOT_MET — keystone:
{ "error": "PREREQUISITES_NOT_MET", "message": "Keystone 'keystone.unshaken-step' requires quest 'quest.001-first-road' to be completed.", "details": { "entryIndex": 0, "id": "keystone.unshaken-step", "missingQuestId": "quest.001-first-road" }}INSUFFICIENT_POINTS:
{ "error": "INSUFFICIENT_POINTS", "message": "Walker does not have enough available points for this batch.", "details": { "available": 1, "required": 3 }}Server flow
- Authenticate JWT → resolve
walkerId. - Validate request body via Zod schema. 400 on failure.
- Load walker row from DB. 404 if not found.
- Load all active node and keystone allocations for the walker.
- Load quest progress rows for the walker.
- For each entry (fail-fast): run the 4 per-entry validation checks listed above.
- Run the aggregate points check (check 5).
- Execute single Prisma transaction: insert
TreeAllocationorKeystoneAllocationrows, decrementwalker.availablePoints. - Return
getState(walkerId)(200).
Idempotency
In mock mode: at-most-once insertion is enforced by the DB unique constraints on (walkerId, nodeId) / (walkerId, keystoneId). Re-submitting an already-allocated entry returns 409 ALREADY_ALLOCATED. Clients should treat 409 as “already done” and proceed.
Production phase will introduce a persistent allocation_idempotency_keys table so that re-submission with the same idempotencyKey returns the original 200 response regardless of batch state.
Open follow-ups
POST /tree/keystone/allocate(phase 11): exclusive-with conflict check + hidden keystone visibility.POST /tree/respec(phase 11+): full point refund policy — mechanics-designer authors.- Durable idempotency-key table for production correctness under partial-batch scenarios.