Skip to content

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-AppCheck header is ignored.
  • All allocations are non-provisional. provisional is always false — 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 409 ALREADY_ALLOCATED rather than a cached 200. A durable idempotency-key table (full idempotency) is a production-phase concern.

Endpoint

FieldValue
MethodPOST
Path/tree/allocate
AuthInternal session JWT (Bearer)
IdempotentYes (see idempotency section)
Rate limit30 req/min/walker

Request

Headers

HeaderRequiredNotes
AuthorizationyesBearer <internal-session-JWT>
X-Firebase-AppCheckyesStandard 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"
}
FieldTypeConstraintsNotes
allocationsarraymin 1, max 50Ordered list of entries to allocate.
allocations[].type"node" or "keystone"requiredDetermines which data-layer lookup is used.
allocations[].idstringnon-emptyReferences data/src/content/nodes/ or data/src/content/keystones/ by string id.
allocations[].clusterIdstringnon-emptyInformational — used for client routing; server re-resolves cluster from the data layer.
idempotencyKeyUUID v4requiredRe-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:

  1. Entity exists — id present in the data layer. → 404 NODE_NOT_FOUND / KEYSTONE_NOT_FOUND
  2. Not already allocated — walker has no active (non-refunded) allocation for this id. → 409 ALREADY_ALLOCATED
  3. Prerequisites met:
    • Nodes: all ids in node.requires[] must be active allocations for this walker. → 422 PREREQUISITES_NOT_MET (details include missingPrerequisiteIds)
    • Keystones: keystone.unlockQuestId must be in walker’s completed QuestProgress. → 422 PREREQUISITES_NOT_MET (details include missingQuestId)
  4. Region accessiblewalker.totalLifetimeSteps >= region.gatingSteps for the entity’s cluster’s region. → 403 REGION_NOT_ACCESSIBLE

After all per-entry checks pass, a single final check:

  1. Sufficient pointswalker.availablePoints >= sum of all entry costs (nodes: node.cost; keystones: 1). → 422 INSUFFICIENT_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

  1. Authenticate JWT → resolve walkerId.
  2. Validate request body via Zod schema. 400 on failure.
  3. Load walker row from DB. 404 if not found.
  4. Load all active node and keystone allocations for the walker.
  5. Load quest progress rows for the walker.
  6. For each entry (fail-fast): run the 4 per-entry validation checks listed above.
  7. Run the aggregate points check (check 5).
  8. Execute single Prisma transaction: insert TreeAllocation or KeystoneAllocation rows, decrement walker.availablePoints.
  9. 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.