# Run cases API

> List cases in a run, including live lifecycle status and custom-field option keys.

`GET /api/v1/runs/{runUlid}/cases` returns every case that belongs to an open
or closed test run. Two fields were added as **additive, backward-compatible
extensions** — existing integrations that ignore unknown keys are unaffected.

## List cases in a run

```
GET /api/v1/runs/{runUlid}/cases
```

Requires `viewer`+ on the run's project. Returns **404** when the run is
outside the caller's organization.

### Response shape (per item)

Each item in the `cases` array includes the usual run-case fields plus:

| Field                                 | Type             | Description                                                                                                                                                                                                                                                                                                                                                                                                             |
| ------------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `caseLifecycleStatus`                 | `object \| null` | The source case's **current** (live, not snapshotted) lifecycle status. `null` when the status field is unset or the source case has been deleted.                                                                                                                                                                                                                                                                      |
| `customFieldValues[].optionSystemKey` | `string \| null` | The option's own system key (e.g. `status_active`, `status_draft`). `null` for custom (user-defined) options and for non-select field types.                                                                                                                                                                                                                                                                            |
| `executedByApiToken`                  | `object \| null` | The API token that executed the case's **current attempt** (its latest result in the run): `{ ulid, name, app }`, where `app` is the CI reporter (`junit`, `playwright`, `jest`, `cypress`, `vitest`, `cucumber`) of an organization app token and `null` for a personal API token. `null` when the case has no result in the run, when the current attempt was recorded in the web app, and once the token is deleted. |
| `caseRevision`                        | `number \| null` | The revision of the source case this run case froze: its title, fields and steps are that version of the case. `null` only for a run case created before revisions existed whose case was already deleted.                                                                                                                                                                                                              |
| `caseCurrentRevision`                 | `number \| null` | The source case's current revision (`currentRevision` on the test case). `null` when the case was deleted.                                                                                                                                                                                                                                                                                                              |
| `caseUpdated`                         | `boolean`        | `true` when the run is open, the run case is pending (`untested`, or `retested` after a retry) and the case changed after this run case froze it (`caseCurrentRevision` > `caseRevision`). Always `false` for a completed run case and in a closed run.                                                                                                                                                                 |
| `canApplyLatest`                      | `boolean`        | `true` when `caseUpdated` is `true` and the current attempt has no step results yet, so [apply latest version](#apply-the-latest-case-version) can update it.                                                                                                                                                                                                                                                           |

#### `executedByApiToken` is not an assignee

A CI reporter writes with an app token, which is no member, so a case it reports keeps
`assigneeUlid: null` unless a member is assigned (for example by the report's `assignFailedTo`).
`executedByApiToken` only says which token executed the current attempt, and a later attempt
recorded by a member replaces it with `null`. The web app shows it in the assignee column of a
case no member is assigned to; in a run an app token created (a CI run), a case with neither shows
the run's token (its `author`) there instead. Neither is an assignee: filtering by assignee, the assignee
result lock, "assigned to me", and `PATCH`/bulk assign still see the case as unassigned.

#### Opening a case assigns only an empty slot

`POST /api/v1/runs/{runUlid}/cases/{runCaseUlid}/open` auto-assigns the opener (or the run's
default assignee) only when the project's `autoAssignOnOpen` is on and the case's assignee slot is
empty. The slot is not empty, and opening assigns nobody, when:

- a member is assigned — opening never changes a member assignee;
- the run was created by an organization app token, a CI reporter (its `author.kind` is
  `api_token` and its `author.app` is set): no case of a CI run is ever auto-assigned on open, even
  after a member marks or retries it. A run a personal API token created is not a CI run;
- in any other run, an API token executed the case's current attempt (`executedByApiToken` is
  set). Once a member records a newer attempt, opening assigns again.

For a case's current attempt, app tokens and personal API tokens count alike. A case removed from a run and added back keeps its
earlier results in the run, so its current attempt is still the latest of them.

#### `caseLifecycleStatus` object

When non-null, the object has the same shape as a `CustomFieldValueEntry`:

| Field             | Type             | Description                                                                                     |
| ----------------- | ---------------- | ----------------------------------------------------------------------------------------------- |
| `optionUlid`      | `string`         | ULID of the selected lifecycle option                                                           |
| `optionName`      | `string`         | Display name of the option, resolved for the request locale (see below)                         |
| `optionColor`     | `string \| null` | Hex color, if any                                                                               |
| `optionIcon`      | `string \| null` | Icon identifier, if any                                                                         |
| `optionSystemKey` | `string \| null` | System key — `status_active`, `status_draft`, `status_deprecated`, or `null` for custom options |

**Request locale.** `optionName` resolves according to the request's
`Accept-Language` header: an organization that has translated its status
option names into Spanish sees the Spanish name when it sends
`Accept-Language: es`. An absent or unsupported locale resolves to the
English name, so integrators that never send the header see byte-identical
responses to before this behavior existed.

### Live vs. snapshot

Test case **content** (title, steps, description) is **snapshotted** when the
case is added to a run — edits to the source case do not affect any existing
run. `caseLifecycleStatus` is deliberately **not** snapshotted: it is resolved
live at query time via a join to the source case, so it always reflects the
case's current status in the repository. If you need the snapshotted state,
use the `priority` field (which is part of the snapshot).

The run case records which revision of the case it froze (`caseRevision`). While the run is open,
an edit to the case sets `caseUpdated: true` on run cases still waiting to be executed (`untested`,
or `retested` after a retry); the run case keeps the old content until you
[apply the latest version](#apply-the-latest-case-version). A completed run case and a closed run
never report an update.

### Example

```bash
curl -sS \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  "https://app.probara.net/api/v1/runs/01J...RUN/cases"
```

```json
{
  "cases": [
    {
      "runCaseUlid": "01J...RC",
      "caseUlid": "01J...CASE",
      "caseNumber": 7,
      "titleSnapshot": "Checkout flow",
      "status": "untested",
      "caseLifecycleStatus": {
        "optionUlid": "01J...OPT",
        "optionName": "Draft",
        "optionColor": "#F59E0B",
        "optionIcon": "circle-dashed",
        "optionSystemKey": "status_draft"
      },
      "customFieldValues": [
        {
          "fieldUlid": "01J...FLD",
          "fieldName": "Priority",
          "optionUlid": "01J...PRI",
          "optionName": "High",
          "optionColor": "#EF4444",
          "optionIcon": null,
          "optionSystemKey": null
        }
      ]
    }
  ]
}
```

When the source case has no lifecycle status set, or the case was deleted:

```json
{
  "caseLifecycleStatus": null
}
```

When a `customFieldValues` entry belongs to a **system option** (lifecycle status
field):

```json
{
  "optionSystemKey": "status_active"
}
```

When it belongs to a **custom option** (user-defined):

```json
{
  "optionSystemKey": null
}
```

## List a run's case keys

```
GET /api/v1/runs/{runUlid}/case-keys
```

Returns each case now in the run with only what a CI reporter needs to run the tests linked to it:
its display id and its [automation key](https://docs.probara.net/en/developers/api-reference/#automation-key). A framework
reporter uses it to run only the tests of an existing run's cases.

| Field           | Type             | Description                                        |
| --------------- | ---------------- | -------------------------------------------------- |
| `caseDisplayId` | `string`         | The case's display id, for example `ACME-14`       |
| `automationKey` | `string \| null` | The case's automation key, `null` when it has none |

- A case removed from the run is not listed, and neither is a case deleted from the project.
- Items are ordered by case ULID and cursor-paged: `limit` (default 50, max 200; above that
  answers `422 validation_failed`) and `cursor`, the `nextCursor` of the previous page. `nextCursor`
  is `null` on the last page.
- Closed and aborted runs are readable.
- It is the one read an [app token](https://docs.probara.net/en/developers/authentication/#allowed-calls) may make. An app
  token reads only runs of its organization; any other run answers `404 not_found`, and on an
  organization without a paid plan it answers `403 forbidden`, like every app-token call.
- A run outside the caller's organization answers `404 not_found`. A session or an OAuth token also
  needs access to the run's project and is refused `403 forbidden` without it; a personal API token
  or an app token reaches every project of its organization, private projects included.

```bash
curl -sS \
  -H "Authorization: Bearer $PROBARA_APP_TOKEN" \
  "https://app.probara.net/api/v1/runs/01J...RUN/case-keys?limit=200"
```

```json
{
  "items": [
    { "caseDisplayId": "ACME-14", "automationKey": "cart > adds an item" },
    { "caseDisplayId": "ACME-15", "automationKey": null }
  ],
  "nextCursor": null
}
```

## Get a case of a run

```
GET /api/v1/runs/{runUlid}/cases/{runCaseUlid}
```

Returns the run case with its steps, `linkedDefects`, and what belongs to its **current attempt**
(the latest result of the case in the run): `resultUlid`, `resultAttachments` and, for a result a
CI report recorded, what the execution reported:

| Field         | Description                                                                                                                                                                                                                                                                                                          |
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `parameters`  | The parameters the current attempt reported, `[{ name, value }]` in the order sent                                                                                                                                                                                                                                   |
| `links`       | The links the current attempt reported, `[{ url, name }]` in the order sent (`name` is `null` when none was sent)                                                                                                                                                                                                    |
| `resultSteps` | The steps the current attempt reported, flattened to depth-first pre-order: `{ position, parentPosition, action, status, durationMs, error, expected, data }`, where `position` is the step index from 0 (the `stepIndex` of a `resultAttachments` item) and `parentPosition` its parent's (`null` at the top level) |

All three are `[]` when the current attempt reported none — a result marked in the web app, or a case
never marked — and follow the current attempt: a later attempt that reported nothing empties them.
They are the same lists the [attempt snapshot](https://docs.probara.net/en/developers/api/run-result-snapshots/) returns for
`resultUlid`. `resultSteps` is not `steps`: `steps` are the case's own steps. Each item of `steps` carries `keyword`, the Gherkin keyword of a step of a Gherkin case, frozen with the step; it is `null` on a classic step.

## Mark a case

```
PATCH /api/v1/runs/{runUlid}/cases/{runCaseUlid}
```

Sets `assigneeUlid` / `status` / `elapsedMs`. A `status` change marks every step and appends a
result row; the response's `resultUlid` carries the appended attempt (`null` for an
assignee/elapsed-only patch that changes no status). Requires `member`+ on the run's project;
`viewer` receives `403 forbidden`.

### Closed-run behavior

Allowed on an **open** run or a **closed, non-aborted** run when the project's
`allowResultsInClosedRuns` [test-run setting](https://docs.probara.net/en/developers/api/project-run-settings/) resolves
to `true` (the code default). An organization or project that sets it to `false` restores the
older, stricter behavior: a closed run rejects the mark with `409 conflict`. An **aborted** run
always rejects it, under every setting value.

### `403 run_case_assignee_locked`

When the project's `assigneeResultLock` setting is on and the case has an assignee, only that
assignee — or a caller exempt via `projects.manage` or the project's current owner — may mark
it. Every other caller is refused, **including an API token**: there is no machine-actor
exemption for this lock.

```json
{
  "error": {
    "code": "run_case_assignee_locked",
    "message": "Only the assigned tester may record a result for this case"
  }
}
```

### `422 elapsed_ms_required`

When the project's `timeTracking` setting is `"required"` and the request sets a terminal
`status` with no `elapsedMs`, the mark is rejected and nothing is persisted:

```json
{
  "error": {
    "code": "validation_failed",
    "message": "elapsedMs is required",
    "details": { "code": "elapsed_ms_required", "path": ["elapsedMs"] }
  }
}
```

Under `"off"` or `"optional"`, a supplied `elapsedMs` is still accepted and persisted — the
setting only governs whether the web client's time-tracking UI is shown and whether a mark with
no `elapsedMs` is rejected, never whether a supplied value is dropped.

**Bulk mark and bulk submit-result are exempt from this rejection** — neither
`POST /runs/{runUlid}/cases/bulk-mark` nor `POST /runs/{runUlid}/cases/bulk-submit-result`
carries a per-case `elapsedMs` field the server could require, so `timeTracking: "required"`
never blocks either bulk endpoint.

See [Test-run settings](https://docs.probara.net/en/developers/api/project-run-settings/) for the full settings
vocabulary and resolution chain.

## Apply the latest case version

```
POST /api/v1/runs/{runUlid}/cases/{runCaseUlid}/apply-latest
POST /api/v1/runs/{runUlid}/cases/apply-latest
```

Brings a run case whose test case was edited after the run froze it to the case's latest version:
its title, suite, description, preconditions, postconditions, priority and steps are replaced, and
`caseRevision` becomes the case's current revision. The assignee, position and status do not change.
Send an empty JSON body (`{}`). Requires `member`+ on the run's project, like marking a case.

A run case can take the latest version only while it is **pending** (`untested`, or `retested`
after a retry) and its **current attempt has no step results**: no step with a status, an actual
result or an attachment. To update a completed case, retry it first
(`POST /api/v1/runs/{runUlid}/cases/{runCaseUlid}/retry`). Earlier
attempts keep the title, fields, steps, step results and attachments they were executed with, so
every recorded result stays tied to the steps that were actually run. `canApplyLatest` tells you
in advance.

The single-case endpoint answers the run case with its steps, like `GET`. It refuses with `409
conflict` and changes nothing in the cases below. They are checked in this order, and the first
that applies is the one reported:

| Situation                                                                   | `error.message` / `error.details.reason` |
| --------------------------------------------------------------------------- | ---------------------------------------- |
| The run is closed                                                           | `run is closed`                          |
| The source case was deleted                                                 | `case_deleted`                           |
| The source case has not changed since this run case froze it, in any status | `case_not_outdated`                      |
| The run case is completed, or its attempt has step results                  | `run_case_has_results`                   |

`case_not_outdated` compares `caseRevision` with `caseCurrentRevision`; it does not follow
`caseUpdated`, which is `false` for every completed run case. A completed run case whose source
case changed answers `run_case_has_results`: retry it first.

When the project's `assigneeResultLock` is on, the same [`403 run_case_assignee_locked`](#403-run_case_assignee_locked)
rule as marking applies.

The run-wide endpoint applies the latest version to every run case with `caseUpdated: true`
whose current attempt has no step results, in one transaction, and reports the outdated pending
cases it left unchanged (completed cases are never candidates):

```json
{
  "updated": ["01J...RC1"],
  "skipped": [{ "runCaseUlid": "01J...RC2", "reason": "run_case_has_results" }]
}
```

`reason` is `run_case_has_results`, or `assignee_locked` when the assignee lock keeps the caller
from changing that case. A closed run answers `409 conflict` (`run is closed`).

Each updated run case records `run.case_version_applied` (`fromRevision`, `toRevision`); the
run-wide call records one `run.cases_bulk_version_applied` with the updated cases, or nothing when
no case was updated. See [Audit events API](https://docs.probara.net/en/developers/api/audit-events/).

## Reconcile a run's case selection

```
PATCH /api/v1/runs/{runUlid}/cases
```

Reconciles a run's case selection to a supplied **desired full
selection**, in a single atomic transaction. Unlike `POST /runs/{runUlid}/cases`
(add-only — appends the given case ULIDs and never removes anything), this
endpoint treats absence from the desired set as a **removal**. Both endpoints
remain available and unchanged relative to each other.

Requires `member`+ (the same execute gate as running the tests); `viewer`
receives `403`. Allowed on an **open** run or a **closed, non-aborted** run
(completed, or auto-closed once every case had a result); rejects only an
**aborted** run with `409`, mutating nothing.

### Request body

```json
{
  "caseUlids": ["01J...A", "01J...B", "01J...D"],
  "caseAssignees": [{ "caseUlid": "01J...D", "assigneeUlid": "01J...USER" }]
}
```

| Field           | Type               | Description                                                                                                                                                          |
| --------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `caseUlids`     | `string[]`         | The full desired selection (source case ULIDs). An **empty array is valid** — it removes every case from the run.                                                    |
| `caseAssignees` | `array` (optional) | Per-case assignee for the desired selection. A case ULID **absent** from this array means that case is (or becomes) unassigned — there is no explicit "clear" value. |

Given the current selection and the request above, the server diffs and applies, atomically:

- **Adds** each case in `caseUlids` not currently on the run — snapshotting its title and steps fresh, `status: "untested"`, with the supplied assignee (if any) applied inline.
- **Removes** each current case absent from `caseUlids` — cascading its step snapshots and result attachments, same as the single-case `DELETE /runs/{runUlid}/cases/{runCaseUlid}` endpoint.
- **Retains** each case present in both, unchanged (`titleSnapshot`, `status`, `assigneeUlid`, `elapsedMs`).
- **Reassigns** any retained case whose supplied assignee differs from its current one.

The response is the reconciled run summary (`RunResponse`), the same shape every other run mutation returns — it does **not** include the run-cases array; call `GET /runs/{runUlid}/cases` for the refreshed list.

### Assignee validation

Each supplied `assigneeUlid` must resolve to a member of the run's
organization. **A cross-tenant or unknown assignee ULID returns `404
not_found`** — matching the existing create (`POST /projects/{projectId}/runs`)
and per-case patch (`PATCH /runs/{runUlid}/cases/{runCaseUlid}`) behavior for
the same field, not a `422 validation_failed`. No case is added, removed, or
reassigned when this happens.

### Audit events

Emits exactly one event per case that actually changed: `run.case_added` for
each added case, `run.case_removed` for each removed case, `run.case_assigned`
for each case whose assignee changed (including a clear, `to: null`) — see
[Audit events API](https://docs.probara.net/en/developers/api/audit-events/) for the shared metadata
shapes. A case unchanged in both membership and assignee emits nothing. A
newly added case's inline assignee is carried on `run.case_added` and does
**not** additionally emit `run.case_assigned`.

### Example

```bash
curl -sS -X PATCH \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"caseUlids": ["01J...A", "01J...D"]}' \
  "https://app.probara.net/api/v1/runs/01J...RUN/cases"
```

## Only approved cases

When the project's [review settings](https://docs.probara.net/en/developers/api/project-review-settings/) have
`onlyApprovedInRuns` on (Pro or Enterprise plan, with review `enabled`), a run takes in only test cases whose
`reviewStatus` is `approved`:

- **Cases you choose** — `caseUlids` on `POST /api/v1/projects/{projectId}/runs`, on
  `POST /runs/{runUlid}/cases` and the cases a `PATCH /runs/{runUlid}/cases` adds — must all be
  approved. Otherwise the whole request is refused and nothing changes:

  ```json
  {
    "error": {
      "code": "validation_failed",
      "message": "only approved test cases can be added to runs in this project",
      "details": {
        "reason": "cases_not_approved",
        "notApprovedCaseUlids": ["01J...B"]
      }
    }
  }
  ```

  Cases already in the run are kept by a reconcile even when they are no longer approved.

- **Cases a run takes in on its own** — a test plan's selection when `caseUlids` is omitted, and the
  cases of the source run of a clone — are filtered: only the approved ones are added, and the
  answer carries `notApprovedSkippedCount`, how many were left out. When none is approved the
  request is refused with `422 validation_failed` (`details.code` `empty_selection`).

With the option off, review disabled, or a plan without test case review, nothing changes and
`notApprovedSkippedCount` is absent. CI reports (`POST /api/v1/projects/{projectId}/reports`) are
not affected.

## Request limits

Each of the three endpoints below caps the `caseUlids` array in its request
body. These are abuse ceilings, not product ceilings — the server processes
any accepted size without a bound-parameter failure. Submitting more than the
limit returns `422 validation_failed` naming the `caseUlids` field, and
mutates nothing.

| Endpoint                                              | Field       | Limit |
| ----------------------------------------------------- | ----------- | ----- |
| `POST /api/v1/runs/{runUlid}/cases` (add cases)       | `caseUlids` | 500   |
| `PATCH /api/v1/runs/{runUlid}/cases` (reconcile)      | `caseUlids` | 5000  |
| `POST /api/v1/projects/{projectId}/runs` (create run) | `caseUlids` | 5000  |

## Parameterized run cases

A parameterized test case has one run case for each combination, and each run case carries its combination in `parameters`. The run case list returns it on every item (`[]` for a case without parameters). The run case detail adds `siblings`, the other run cases of the same case in the run, with their status, assignee, and parameters, and `reportedParameters`, the parameters the current attempt reported.

`POST /api/v1/runs/{runUlid}/cases/{runCaseUlid}/apply-latest` refuses with `409 conflict` and `details.reason: combination_removed` when the latest version of the case no longer produces the run case's combination. The bulk apply, `POST /api/v1/runs/{runUlid}/cases/apply-latest`, lists the same reason under `skipped`.

`GET /api/v1/runs/{runUlid}/case-keys` returns one entry for each run case, with its `parameters`, so a reporter can tell the run cases of one test case apart.

For the rules behind these fields, see [Parameterized test cases](https://docs.probara.net/en/guides/parameterized-test-cases/) and the [API reference](https://docs.probara.net/en/developers/api-reference/#parameterized-test-cases).

## Related

- [API reference overview](https://docs.probara.net/en/developers/api-reference/) — pagination, errors, authentication
- [Interactive v1 reference](https://docs.probara.net/en/developers/api-reference/v1/) — full schemas
- [Test case search API](https://docs.probara.net/en/developers/api/test-case-search/) — cross-suite search including `customFieldValues`
