# Create a Playground run

```http
POST /api/public/v1/competitions/lean-kernel-challenge/playground/runs
Content-Type: application/json
```

**Scope**: `playground.write`.

Creates one practice evaluation from an exact revision of a saved Solution owned by the caller. The server freezes the complete Workspace and current evaluation contract into an immutable execution snapshot. A Playground Run never creates a formal submission, competition result, or leaderboard entry.

For a new Run, the caller must be enrolled, the Playground must be open for new work, and the saved Solution's Problem must still be available.

## Request fields

| Field | Type | Required | Description |
| :--- | :--- | :---: | :--- |
| `solutionId` | string | Yes | Opaque ID returned by [Save a solution](./save-playground-solution.md) or [List saved solutions](./list-playground-solutions.md). The Solution must be owned by the caller. |
| `revision` | integer | Yes | Exact current positive revision of the saved Solution. |
| `idempotencyKey` | string | Yes | Non-empty client-generated key of at most 200 UTF-8 bytes after surrounding whitespace is removed. |

## Idempotency

Generate one unpredictable `idempotencyKey` for each intended Run, persist it with that operation, and reuse it when retrying after a timeout or uncertain response. Use a new key when the user intends to create another Run, even if the Solution revision is unchanged. Keys are scoped to the caller's account and this Competition.

The server resolves a known key before applying new-Run lifecycle, enrollment, revision, and quota checks. An exact replay therefore returns the original Run even if the Solution was later edited or the Playground became read-only. It does not create another evaluation or consume another Run allowance. After the Run is recorded, an exact replay returns its current state with `200 OK`. Reusing the key with a different `solutionId` or `revision` returns `409 IDEMPOTENCY_CONFLICT`.

## Admission and execution

- New admission validates ownership, the exact Solution revision, the complete saved Workspace, the current Problem, enrollment, and the Playground lifecycle before reserving capacity.
- The active-Run limit is scoped to the caller and Competition. The daily allowance is scoped to the caller, Competition, Problem, and UTC date. Validation failures do not create a Run or consume either allowance.
- Automatic evaluator retries keep the same `runId`, execution snapshot, allowance, and active slot. A Run admitted before the Playground becomes read-only may continue to completion.
- A Run that is still service-confirmed as queued can be [cancelled](./cancel-playground-run.md), including after the Playground becomes read-only. A successful cancellation returns its reserved daily allowance.

## Example request

```bash
curl -X POST "https://api.sair.foundation/api/public/v1/competitions/lean-kernel-challenge/playground/runs" \
  -H "Authorization: Bearer $SAIR_API_KEY" \
  -H "Content-Type: application/json" \
  --data '{
    "solutionId": "17",
    "revision": 1,
    "idempotencyKey": "9c85bd36-837c-4b5d-8714-9f994d1ef297"
  }'
```

## Response

A newly created Run returns `201 Created`. An exact replay returns `200 OK` with the original Run's current state.

### Response fields

| Field | Type | Description |
| :--- | :--- | :--- |
| `runId` | string | Opaque Run ID. Use it to poll the Run and correlate History and Result views. |
| `solutionId` | string | Opaque ID of the saved Solution used for this Run. |
| `solutionRevision` | integer | Exact Solution revision captured by the execution snapshot. |
| `solutionName` | string | Solution name captured when the Run was created. |
| `snapshotId` | string | Opaque ID of the immutable execution snapshot. |
| `problemId` | string | Problem evaluated by the Run. |
| `status` | string | Run lifecycle: `pending`, `running`, `done`, `failed`, or `cancelled`. |
| `statusUncertain` | boolean | `true` when live evaluator status cannot be confirmed. The other status and progress fields then remain at their last confirmed values. |
| `canCancel` | boolean | Authoritative cancellation signal. `true` only while the evaluator confirms that the Run is still queued. |
| `historyCategory` | string | Derived History category: `active`, `accepted`, `rejected`, `failed`, or `cancelled`. It is not a separate Run state. |
| `finalConclusion` | string \| null | `passed` or `failed` after a normal evaluation; otherwise `null`. A Run with `status: "failed"` has no final conclusion because evaluation did not finish normally. |
| `verdict` | string \| null | Evaluator verdict, normally `accepted`, `rejected`, or `null`. |
| `score` | string \| null | Optional development-only display summary. Use `metrics` for structured measurements. |
| `reasonCode` | `"REJECTED" \| "RESOURCE_LIMIT" \| "INFRA_ERROR" \| "JUDGE_ERROR" \| null` | Stable public reason classification when the Run or evaluation did not succeed. |
| `reasonMessage` | string \| null | Human-readable explanation for `reasonCode`. |
| `logExcerpt` | string \| null | Sanitized evaluator output when available. |
| `metrics` | [`PracticeMetrics`](./get-playground-run.md#practicemetrics) \| null | Structured development measurements, or `null` before any measurement exists. |
| `phase` | `"prepare" \| "queue" \| "judge" \| "retry"` | Coarse evaluator phase. Omitted when no reliable phase is available. |
| `progressText` | string | Human-readable progress text. Omitted until available. |
| `failureCategory` | `"RESOURCE_LIMIT" \| "INFRA_ERROR" \| "JUDGE_ERROR"` | Authoritative evaluator failure classification. Omitted unless available. |
| `attempt` | integer | Current automatic evaluator attempt, starting at `1`. |
| `maxAttempts` | integer | Maximum automatic evaluator attempts for this Run. |
| `nextRetryAt` | string | ISO 8601 UTC time for the next automatic retry. Omitted when no retry is scheduled. Its presence is authoritative even when `attempt` already equals `maxAttempts`, because `attempt` identifies the scheduled attempt. |
| `pipelineVersion` | string | Version of the progress and result pipeline. |
| `progressObservedAt` | string \| null | ISO 8601 UTC time when the returned live progress was observed, or `null` for an uninstrumented legacy Run. |
| `todos` | [`RunTodo[]`](#runtodo) | Ordered evaluator progress steps. |
| `stages` | [`RunStages`](#runstages) | Coarse compatibility projection of the four evaluator stages. Omitted when no reliable evaluator projection is available. New clients should use `todos` for the primary progress UI. |
| `ruleVersion` | string | Rules version frozen into the execution snapshot. |
| `templateVersion` | string | Template version frozen into the execution snapshot. |
| `toolchainVersion` | string | Toolchain version frozen into the execution snapshot. |
| `practicePolicy` | [`PracticePolicy`](./get-playground-run.md#practicepolicy) | Frozen public-practice policy. Omitted on the initial `201` receipt and for retained legacy Runs; retrieve the Run to read it. |
| `practicePlan` | [`PracticeCase[]`](./get-playground-run.md#practicecase) | Exact ordered public cases frozen for this Run. Omitted on the initial `201` receipt and for retained legacy Runs; retrieve the Run to read it. |
| `dailyAllowance` | `"returned"` | Confirms that the reserved daily allowance was returned. Present only on a successful cancellation response. |
| `createdAt` | string | ISO 8601 UTC time when the Run record was created. |
| `queuedAt` | string | ISO 8601 UTC time when the Run was queued. Omitted until available. |
| `startedAt` | string | ISO 8601 UTC time when evaluation started. Omitted until available. |
| `completedAt` | string | ISO 8601 UTC time when the Run reached a terminal state. Omitted until available. |
| `cancelledAt` | string | ISO 8601 UTC time when cancellation was confirmed. Omitted unless `status` is `cancelled`. |
| `cancellationReason` | string | Participant-facing cancellation reason. Omitted unless `status` is `cancelled`. |
| `evaluationResult` | `"not_produced"` | Confirms that cancellation produced no evaluation result. Omitted for other states. |
| `message` | string | Cancellation confirmation. Omitted unless the cancellation response supplies it. |

### `RunTodo`

| Field | Type | Description |
| :--- | :--- | :--- |
| `key` | string | Stable step key within the returned pipeline version. |
| `label` | string | Human-readable step label. |
| `status` | string | `pending`, `running`, `done`, `failed`, or `skipped`. |
| `startedAt` | string \| null | ISO 8601 UTC start time, or `null`. |
| `completedAt` | string \| null | ISO 8601 UTC completion time, or `null`. |
| `durationMs` | integer \| null | Recorded step duration in milliseconds, or `null`. |

For every non-cancelled instrumented Run, `todos` is non-empty, `progressObservedAt` is non-null, and each Todo `key` is unique in the returned array. A retry appends another attempt's instances with distinct keys. Only `pipelineVersion: "legacy-uninstrumented"` and cancelled Runs return an empty array with `progressObservedAt: null`.

### `RunStages`

`stages` is an object with exactly four snake-case keys: `comparator`, `r2_audit`, `r3_audit`, and `timing`. Each value is `pending`, `running`, `done`, or `failed`. It is a retained coarse projection for compatible clients; unlike `todos`, it has no `skipped` state, timestamp, duration, label, or pipeline-versioned ordering.

```json
{
  "ok": true,
  "data": {
    "runId": "31",
    "solutionId": "17",
    "solutionRevision": 1,
    "solutionName": "baseline",
    "snapshotId": "24",
    "problemId": "fib",
    "status": "pending",
    "statusUncertain": false,
    "canCancel": true,
    "historyCategory": "active",
    "finalConclusion": null,
    "verdict": null,
    "score": null,
    "reasonCode": null,
    "reasonMessage": null,
    "logExcerpt": null,
    "metrics": null,
    "attempt": 1,
    "maxAttempts": 2,
    "pipelineVersion": "lean-kernel-2026-08-21.2",
    "progressObservedAt": "2026-09-04T12:00:00Z",
    "todos": [
      {
        "key": "local_worker_queue",
        "label": "Waiting for evaluator capacity",
        "status": "running",
        "startedAt": "2026-09-04T12:00:00Z",
        "completedAt": null,
        "durationMs": 0
      },
      {
        "key": "comparator",
        "label": "Comparator",
        "status": "pending",
        "startedAt": null,
        "completedAt": null,
        "durationMs": null
      },
      {
        "key": "axiom_audit",
        "label": "Axiom audit",
        "status": "pending",
        "startedAt": null,
        "completedAt": null,
        "durationMs": null
      },
      {
        "key": "performance_queue",
        "label": "Waiting for performance measurement",
        "status": "pending",
        "startedAt": null,
        "completedAt": null,
        "durationMs": null
      },
      {
        "key": "performance_measurement",
        "label": "Performance measurement",
        "status": "pending",
        "startedAt": null,
        "completedAt": null,
        "durationMs": null
      }
    ],
    "ruleVersion": "7a0abb4a80d635740a91aed1894f714dfbb82edd",
    "templateVersion": "7a0abb4a80d635740a91aed1894f714dfbb82edd",
    "toolchainVersion": "lean-4.33.1",
    "createdAt": "2026-09-04T12:00:00Z",
    "queuedAt": "2026-09-04T12:00:00Z"
  }
}
```

The source IDs and version fields identify exactly what will be evaluated. Updating the saved Solution later does not change this Run. Poll [Get a Playground run](./get-playground-run.md) until `status` is `done`, `failed`, or `cancelled`. Offer [cancellation](./cancel-playground-run.md) only while the latest response has `canCancel: true`. When `statusUncertain` is true, keep polling and do not infer a terminal result, cancellability, or allowance settlement.

Successful responses use `Content-Type: application/json`.

## Errors

| Error | When |
| :--- | :--- |
| `400 MALFORMED_BODY` | The content type or JSON shape is invalid, a top-level field is unknown or repeated, or a field has the wrong type. |
| `400 INVALID_SOLUTION_ID` | `solutionId` is missing, empty, or is not a valid Solution ID. |
| `400 REVISION_REQUIRED` | `revision` is missing or is not a positive integer. |
| `400 INVALID_IDEMPOTENCY_KEY` | `idempotencyKey` is empty or exceeds 200 UTF-8 bytes after trimming. |
| `403 ENROLL_REQUIRED` | The caller is not enrolled in the Competition. |
| `403 PLAYGROUND_NOT_STARTED` | The Playground has not opened for new work. |
| `403 PLAYGROUND_READ_ONLY` | The Playground is closed or disabled for new work. |
| `404 NOT_FOUND` | The Competition is not visible, or the Solution does not exist or is not owned by the caller. |
| `409 PROBLEM_UNAVAILABLE` | The saved Solution's Problem is not currently available for new Runs. |
| `409 REVISION_CONFLICT` | `revision` is not the Solution's exact current revision. Reload the Solution and use a new idempotency key for a new Run intent. |
| `409 IDEMPOTENCY_CONFLICT` | The same `idempotencyKey` identifies a different `solutionId` or `revision`. |
| `409 RUN_IN_PROGRESS` | The first request using this key is still being recorded. Retry the exact request with the same key. |
| `422 SOURCE_CONTRACT_MISMATCH` | The saved Workspace cannot be executed under the current Solution contract. |
| `429 ACTIVE_RUN_LIMIT_REACHED` | The caller has reached the configured active-Run limit for this Competition. `details` contains `activeLimit` and `activeCount`. |
| `429 DAILY_RUN_LIMIT_REACHED` | The caller has used the configured UTC-day allowance for this Problem. `details` contains `dailyLimit`, `used`, `remaining`, `window`, and `nextResetAt`. |
| `502 PLATFORM_UNAVAILABLE` | Competition visibility, lifecycle, or enrollment cannot be verified. |
| `502 PLAYGROUND_CONFIGURATION_INVALID` | The authoritative Playground lifecycle configuration is invalid. |
| `502 SERVICE_UNAVAILABLE` | The authoritative evaluator is unavailable. Retry an uncertain creation with the same idempotency key. |
| `502 SERVICE_CONTRACT_INVALID` | The current runtime contract cannot be frozen into an execution snapshot. |
| `503 RUN_STORE_BUSY` | Run admission is temporarily busy. Retry the exact request with the same idempotency key. |

See [Errors](../../../errors.md) for shared authentication, scope, and account-rate-limit errors.
