# Probara REST API reference

> OpenAPI 3.1 reference for the Probara integrator API under /api/v1.

Machine-readable contract for integrators: **OpenAPI 3.1** (`/openapi/v1.json`), generated from Zod schemas in `@tcms/shared` and kept in sync in CI.

Use the [interactive v1 reference](https://docs.probara.net/en/developers/api-reference/v1/) for operation-level schemas, request bodies, and response samples.

## Base URL

All integrator routes use the prefix **`/api/v1`**.

| Environment  | Base URL                  |
| ------------ | ------------------------- |
| Production   | `https://app.probara.net` |
| Local Worker | `http://localhost:8787`   |

Example:

```bash
curl -sS -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  "https://app.probara.net/api/v1/projects"
```

Unversioned `/api/...` paths return `404 not_found`.

## Authentication

Use an [organization-scoped API token](https://docs.probara.net/en/developers/authentication/) as `Authorization: Bearer <secret>`. With a valid token you do not need `X-Organization-Id`. To report from CI, use an [app token](https://docs.probara.net/en/developers/authentication/#app-tokens) instead: it belongs to the organization and may only make the [calls the CI reporters make](https://docs.probara.net/en/developers/authentication/#allowed-calls) — the Probara CLI, which also syncs feature files, and the framework reporters.

Session-only routes (`/api/v1/auth/*`, `/api/v1/me/api-tokens`, invitation accept with cookie) are documented in the authentication guide, not in the OpenAPI document.

## Errors

Failed requests return JSON:

```json
{
  "error": {
    "code": "not_found",
    "message": "Human-readable message",
    "details": {}
  }
}
```

Common `code` values: `validation_failed`, `not_found`, `conflict`, `unauthorized`, `forbidden`, `too_many_requests`, `seat_limit_exceeded`, `project_limit_exceeded`, `api_result_limit_exceeded`, `storage_quota_exceeded`, `billing_provider_unavailable`, `organization_plan_required`, `run_case_assignee_locked`, `test_case_synced_read_only`, `project_locked`, `organization_pending_deletion`.

`project_locked` is the one of these that is not about the request you sent. An organization
holding more projects than its plan allows keeps only its most recently created projects open;
every older project is locked, and every read and write against it answers
`403 { code: "project_locked" }` — over this API and over MCP, for a session and for an API key
alike. `DELETE /api/v1/projects/{projectId}` is the one operation a locked project still serves,
because deleting is how the organization brings its total back inside the allowance. Nothing is
deleted by the lock itself: `GET /api/v1/projects` keeps listing the project, with a non-null
`lockedAt`, and raising the organization's allowance clears the stamp and reopens it unchanged.
Treat it as retryable only after a human acts — no amount of retrying will clear it.

`test_case_synced_read_only` means the test case is synced from a `.feature` file in Git. Its scenario is read-only in Probara, so a write that would change it answers `409` with this code and writes nothing. Change the scenario in the file instead: see [Synced cases](#synced-cases).

`organization_pending_deletion` means the organization's deletion is scheduled: an owner or Probara
support requested it, or no member opened the organization in the web app for 12 months. For the
14-day grace period every write answers `403 { code: "organization_pending_deletion" }` — through
this API and through MCP, for an API key exactly as for a session — while every read, export
included, keeps working. Nothing about the request is wrong and retrying will not help: the state
ends only when the deletion is canceled, or when the organization is erased. No API or MCP call
cancels it. A deletion scheduled for inactivity is canceled when a member opens the organization in
the web app, and that member's writes then go through; API keys and OAuth tokens do not count as
activity, even while they are in use. `GET /api/v1/orgs/current` reports the state as
`pendingDeletion: { requestedAt, scheduledFor }` (`null` otherwise). See
[Delete an organization](https://docs.probara.net/en/guides/delete-organization/).

A public run share link belonging to a locked project answers the same byte-identical
`404 { code: "not_found" }` an unknown token earns, deliberately: the holder of a valid link must
not be able to use it to learn that the organization is over its allowance.

`details` occasionally carries its own `code`, distinct from the top-level one — for example a
`validation_failed` mark rejection carries `details: { code: "elapsed_ms_required", path:
["elapsedMs"] }`. Always branch on the top-level `code` first; treat `details.code` as
supplementary, never a replacement.

## Pagination

Table list endpoints (projects, milestones, runs, defects, organization members, invitations, user groups, custom fields, and the archived test-case listing below) use **page-based pagination**. They accept `page` (1-based, default `1`) and `pageSize` (default `50`, max `200`). Out-of-range or non-integer values return `422 validation_failed`. Responses carry the page, its size, and the total count of all rows matching the active filters and search — not just the rows on the page:

```json
{
  "items": [],
  "page": 1,
  "pageSize": 50,
  "total": 0
}
```

A `page` beyond the last available page returns `200` with `items: []` and the real `total`. Many table endpoints also accept a `q` query param for case-insensitive substring search; `q` is applied before pagination and reflected in `total`.

**Cursor pagination (feeds and trees).** Chronological feed endpoints (audit events and their run/defect-scoped variants, and a test case's version history) and the suite/test-case tree endpoints (including the default, active-only `GET /api/v1/projects/{projectId}/test-cases` and its `archived=true` filter) keep cursor pagination instead: `limit` (default 50, max 200) and optional `cursor` (ULID), responding with `{ "items": [], "nextCursor": "01ARZ3…" }`. When `nextCursor` is `null`, there are no more pages. The one exception is the **page-based** `GET /api/v1/projects/{projectId}/test-cases/archived` endpoint below, which exists specifically so archived cases can be numbered-page-navigated (Trash) instead of walked as a cursor feed.

## Rate limits

Requests authenticated with an API token, an app token or an OAuth access token count against your organization's plan limit: a number of requests per 60-second window, shared by every credential in the organization. Requests from the web app (session cookie) never count.

Every counted response states the limit and what is left of it, in the fields of the IETF [RateLimit header fields draft](https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/):

```http
RateLimit-Policy: "org";q=60;w=60
RateLimit: "org";r=42;t=17
```

`q` is the limit and `w` the window, in seconds. `r` is the number of requests left and `t` the seconds until the window resets. Past the limit, the request returns `429 too_many_requests` with `RateLimit: "org";r=0;t=…` and a `Retry-After` header holding the same number of seconds: wait that long and send the request again. Pace your client on `r` and `t` to avoid the `429`.

## Versioning

Breaking API changes require a new major path (for example `/api/v2`). `/api/v1` remains until explicitly deprecated.

## Rich text fields

Long text is rich text: the descriptions of test cases, suites, projects, milestones, plans, environments, runs and defects; a test case step's `action`, `data` and `expected`, and a step's actual result; comments; result notes; review notes; and paragraph custom fields, preconditions and postconditions included. Titles, names, labels and other short values stay plain text, and so do Gherkin steps.

Each rich field has two forms, and every response carries both:

- `<field>` is the text as **Markdown**. Integrations, MCP tools, imports and exports use it.
- `<field>Document` is the same text as a **document**: a JSON tree whose `attrs.version` is `1` today. The web app edits this form, and it keeps every formatting detail.

An empty field answers `null` in both forms.

```json
{
  "description": "Open the **cart** and check the total.\n\n![Cart](attachment:01J...IMG)",
  "descriptionDocument": {
    "type": "doc",
    "attrs": { "version": 1 },
    "content": [
      {
        "type": "paragraph",
        "content": [
          { "type": "text", "text": "Open the " },
          { "type": "text", "text": "cart", "marks": [{ "type": "bold" }] },
          { "type": "text", "text": " and check the total." }
        ]
      },
      {
        "type": "image",
        "attrs": { "src": "attachment:01J...IMG", "alt": "Cart", "width": 800, "height": 600 }
      }
    ]
  }
}
```

### Writing a field

Send one form per field, `<field>` or `<field>Document`. Sending both answers `422 validation_failed`, and the message names the pair, for example `send either description or descriptionDocument, not both`. Projects, suites and review notes also set `details.code` to `both_inputs`. Send `null` in either form to clear the field.

Limits count the field's plain text, the text without formatting or image references:

- Test case, paragraph custom field and defect descriptions: 4000 characters.
- Run descriptions and review notes: 2000 characters.
- Comments: 5000 characters.
- A step's actual result: 20000 characters.

Every document is capped at 256 KiB, and so is the Markdown sent in `<field>`, which is checked before it is parsed. A larger value answers `422` with `details.code` `too_large` and `details.limit` `262144`.

A refused value answers `422 validation_failed`. `details.code` names the rule it breaks, and `details.field` names the field:

| `details.code`         | Meaning                                                                                                                             |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `invalid_document`     | The document does not match the schema, or a mention is not a user ULID.                                                            |
| `unsupported_version`  | `attrs.version` is not a version this API reads.                                                                                    |
| `unsupported_link`     | A link uses a protocol other than `http`, `https` or `mailto`. Markdown keeps the link text and drops the link instead of refusing. |
| `unsupported_image`    | An image source is not `attachment:<ULID>`.                                                                                         |
| `mentions_not_allowed` | The field takes no mentions. Only comments do.                                                                                      |
| `too_long`             | The plain text is over the field's limit (`details.limit`).                                                                         |
| `too_large`            | The document or Markdown is over 256 KiB (`details.limit`).                                                                         |
| `unknown_image`        | The text names an image that was never staged or has expired. `details.ulids` lists them, and `details.field` is absent.            |

Headings deeper than level 3 read as level 3. A field takes paragraphs, headings, bold, italic, strikethrough, inline code, links, bullet, numbered and task lists, blockquotes, code blocks, tables and images.

### Images

Images sit inline in the text. Stage the file first, then save the field that uses it:

1. Stage each file with `POST /api/v1/images:stage`: `multipart/form-data` with one or more `file` parts, at most 20 per request. Callers need the `attachments.execute` permission, which members, admins and owners hold. Each file must be PNG, JPEG or WebP, at most 10 MiB, with no side over 8192 pixels. The `201` response lists each image with its `ulid` and a `previewUrl`.
2. Reference the image in the text: Markdown `![alt text](attachment:<ULID>)`, or a document `image` node whose `attrs.src` is `attachment:<ULID>`. Any other source is refused with `unsupported_image`.
3. Save the field. The save copies the staged file to its permanent copy, scaled so its longest edge is at most 2048 pixels and stored as WebP, and the field holds it. An image stays stored while any text or stored revision references it. The save that drops its last reference deletes it. A staged image that no save references is removed after about 48 hours.

Only comments take mentions: `@[ulid]` in Markdown, or a `mention` node with `attrs.id` in a document. See [Test case comments](https://docs.probara.net/en/developers/api/test-case-comments/#mentions).

### Where the text goes

Webhooks, notification emails and the activity feed carry plain-text excerpts, never Markdown or documents. GitHub issue bodies are Markdown, and each image points at its stored asset URL. Jira descriptions and comments are converted to Atlassian Document Format: images become links, and mentions become `@Name`. Inbound Jira comments keep their formatting, and their media is dropped.

## Display ID lookups

Suites and test cases expose stable **display IDs** in API responses (`displayId`, plus `suiteNumber` / `caseNumber`). Integrators can resolve resources by display ID without knowing the ULID:

- `GET /api/v1/projects/{projectId}/suites/by-display-id/{displayId}` — `{projectId}` is the project **code** (for example `ACME`). `{displayId}` must match `{projectId}-S{positiveInteger}` (for example `ACME-S2`). Returns the same `SuiteResponse` shape as `GET /api/v1/suites/{suiteUlid}`.
- `GET /api/v1/projects/{projectId}/test-cases/by-display-id/{displayId}` — `{displayId}` must match `{projectId}-{positiveInteger}` (for example `ACME-14`). Returns the same `TestCaseResponse` shape as `GET /api/v1/test-cases/{caseUlid}`.

Malformed display IDs return `422 validation_failed`. Unknown IDs return `404 not_found`. Prefix mismatches (wrong project code in the suffix) return `400 validation_failed`.

Test runs and milestones carry a per-project number too (`runNumber` / `milestoneNumber`) and a `displayId` built from it. Unlike suites and cases, these display IDs are **not** project-prefixed — the `{projectId}` path segment scopes the lookup:

| Resource  | Display ID | Lookup                                                          | Same shape as                            |
| --------- | ---------- | --------------------------------------------------------------- | ---------------------------------------- |
| Test run  | `R-{n}`    | `GET /api/v1/projects/{projectId}/runs/by-display-id/R-12`      | `GET /api/v1/runs/{runUlid}`             |
| Milestone | `M-{n}`    | `GET /api/v1/projects/{projectId}/milestones/by-display-id/M-3` | `GET /api/v1/milestones/{milestoneUlid}` |

Numbers start at 1, increase with every run or milestone created in the project, and are never reused — a deleted run or milestone leaves a gap. A malformed ID (`R-0`, `R-01`, `R-x`) or the other resource's prefix (`M-3` on the runs lookup) returns `422 validation_failed`; an unknown number, a number from another project, or a deleted milestone returns `404 not_found`. The ULID stays the identifier on every other run and milestone endpoint. The web app uses the same IDs in its URLs, for example `/projects/ACME/runs/R-12` and `/projects/ACME/milestones/M-3`.

**Suite mutations.** Creating (`POST /api/v1/projects/{projectId}/suites`), renaming or moving (`PATCH /api/v1/suites/{suiteUlid}`), and deleting (`DELETE /api/v1/suites/{suiteUlid}`) suites require the suite execute permission (held by `owner`/`admin`/`member`); a `viewer` receives `403 forbidden`. Reading suites (tree, detail, display-ID lookup) is unchanged for any authenticated member.

## Archived test cases (Trash)

`GET /api/v1/projects/{projectId}/test-cases/archived` returns a **page-based**, project-wide listing of archived test cases — most-recently-archived first (`archivedAt` descending, ULID descending as a tie-break). It accepts the standard `page` / `pageSize` params and responds with the standard page shape:

```json
{
  "items": [],
  "page": 1,
  "pageSize": 50,
  "total": 0
}
```

`total` counts **every** archived case in the project, not just the returned page; a `page` past the last one still returns `200` with `items: []` and the real `total`. The listing ignores suite scoping — it always returns archived cases from every suite (and unassigned cases), matching the archived-case exclusion already applied by the default `GET /api/v1/projects/{projectId}/test-cases` listing.

This endpoint is **additive**: it does not replace `GET /api/v1/projects/{projectId}/test-cases?archived=true`, which keeps its existing `{ items, nextCursor }` cursor contract and behavior unchanged. Use the cursor endpoint to walk the full archived set as a feed; use `/archived` to render a numbered-page Trash view.

**Related pages**

- [Restoring archived cases](https://docs.probara.net/en/guides/restoring-archived-cases/) — user-facing walkthrough of the paginated Trash view and cross-page selection.

## Test case custom field values

Test cases no longer carry classification attributes (`priority`, `severity`, `status`, `type`, `layer`, `behavior`, `isFlaky`, `preconditions`, `postconditions`) as top-level body keys. Those attributes are now system custom fields and are read/written via the dedicated custom-field-values contract:

- `GET /api/v1/test-cases/{caseUlid}` includes a `customFieldValues` array with one entry per visible field for the case's project. Missing stored values materialize to the field's `defaultValue` (or `null`).
- `GET /api/v1/projects/{projectId}/test-cases` also embeds `customFieldValues` on each item, following the same shape. The list endpoint assembles the array via a bulk read so the number of D1 round-trips remains constant regardless of page size.
- Each entry carries two optional, nullable keys alongside `fieldUlid` and `value`:
  - `systemKey`: the field definition's `systemKey` (`'priority'`, `'severity'`, `'status'`, `'type'`, `'layer'`, `'behavior'`, `'is_flaky'`, `'preconditions'`, `'postconditions'`) for `group = 'system'` fields, or `null` for `group = 'custom'`.
  - `optionName`: the selected option's `name` (`'High'`, `'Critical'`, `'Active'`, …) for `select_single` / `radio` values when the value is a valid option ULID; `null` for other types or when the value is `null`. `optionName` is resolved for the request locale the same way as [field definitions are now bilingual](#field-definitions-are-now-bilingual) below: `Accept-Language: es` returns the Spanish name when one is authored, falling back to English otherwise — an absent or unsupported locale header resolves to English, so a client that never sends the header sees byte-identical responses to before this change.

  Clients can render system-field chips by reading these keys directly — no separate definitions fetch is required. Older clients that ignore the new keys keep working unchanged.

**Create-time format.** `POST /api/v1/projects/{projectId}/test-cases` accepts an optional `stepFormat` (`classic` or `gherkin`). Omitted, the case takes the project's default step format, set with `defaultStepFormat` on `PATCH /api/v1/projects/{projectId}`.

**Create-time values.** `POST /api/v1/projects/{projectId}/test-cases` accepts an optional `customFieldValues` array in the same `{ fieldUlid, value }` shape as `PUT .../custom-field-values`, so a case is created with its preconditions, postconditions or any other field in one call, at `currentRevision` `1`:

```json
{
  "title": "Checkout with a saved card",
  "customFieldValues": [{ "fieldUlid": "01J...PRECONDITIONS", "value": "A card is saved" }]
}
```

When the array is sent, every required field the project shows must be in it unless the field has a default, which is then stored. An unknown field, a field the project does not show, or an invalid value rejects the whole create with `422 validation_failed` and no case is created. Omitting the array creates the case with every field at its default, as before. The values are recorded in the `test_case.created` event (`metadata.customFieldChanges`).

### Field definitions are now bilingual

Field titles, placeholders, and option names may now carry a Spanish translation. Title and option
name each require a non-empty English value; a placeholder's English value is optional (a field may
have no placeholder at all in either language), so `placeholderI18n.en` may be `null`.
`GET /api/v1/orgs/{orgUlid}/custom-fields` resolves `title`, `placeholder`,
and each option's `name` for the request locale (`Accept-Language: es` returns the Spanish value
when one is authored, falling back to English otherwise — an absent or unsupported locale header
resolves to English, so a client that never sends the header sees byte-identical responses to
before this change). Pass `?locales=all` to additionally receive `titleI18n`, `placeholderI18n`,
and each option's `nameI18n` — a map keyed by locale tag (`{ en, es }`) — alongside the
resolved values. The `q` search parameter matches a field's title stored in **either** locale.

`POST /api/v1/orgs/{orgUlid}/custom-fields`, `PATCH .../custom-fields/{fieldUlid}`, and
`POST .../custom-fields/{fieldUlid}/reset` accept the same `?locales=all` parameter on their
single-object `field` response, gated identically: without it, the response stays
byte-identical to the pre-localization shape (no `*I18n` keys at all), including for
API-key-authenticated integrators.

Export (`GET /api/v1/projects/{projectId}/exports`) is the one exception to locale resolution:
option names in an exported file are deliberately **not** locale-resolved and always stay
English-canonical, so a CSV/JSON export and a re-import always agree on one vocabulary, whoever
downloaded the file. This is unrelated to the `customFieldValues.optionName` documented above,
which is locale-resolved on every ordinary read.

### Consolidated test case update (PATCH)

`PATCH /api/v1/test-cases/{caseUlid}` accepts a consolidated body that bundles scalar field changes, the step list, and custom field values into one request. This is what the web editor uses on save; each call results in **one** audit event with a unified diff.

```json
{
  "patch": {
    "title": "Login fails on mobile",
    "description": "...",
    "suiteUlid": "01J...SUITE",
    "milestoneUlid": null,
    "tags": ["smoke"]
  },
  "steps": [
    { "position": 1, "action": "Open the login screen" },
    { "position": 2, "action": "Submit invalid credentials" }
  ],
  "customFieldValues": [{ "fieldUlid": "01J...PRIORITY", "value": "01J...OPTION_HIGH" }]
}
```

The `patch` may also carry `stepFormat` (`classic` or `gherkin`). The case's steps after the update, the `steps` sent with it or its current ones, must fit the new format, or the update is refused with `422`. A `gherkin` step carries a `keyword` (`Given`, `When`, `Then`, `And`, `But`, or `*`) and sends no `expected`; a `classic` step sends no `keyword`.

At least one of `patch`, `steps`, or `customFieldValues` MUST be present. The handler reads the existing case row, steps, and visible custom field values, applies the requested mutations, computes a unified diff, and emits exactly one `test_case.updated` (or `test_case.moved` when `suiteUlid` changed) audit event. If any sub-mutation fails, the response is non-2xx and no audit event is recorded.

Requires the test case write permission (held by `owner`/`admin`/`member`); a `viewer` token or session receives `403 forbidden` and no field is mutated.

On a synced case the scenario is read-only. A `PATCH` that changes its title, description, steps, step format, `preconditions` value or automation key, or that drops one of its file tags, answers `409 test_case_synced_read_only` and changes nothing. Its suite, assignee, priority and other custom fields stay writable, and so do the tags you add.

The standalone `PUT /api/v1/test-cases/{caseUlid}/steps` and `PUT /api/v1/test-cases/{caseUlid}/custom-field-values` endpoints remain available for direct API integrators. Each records one `test_case.updated` event when it changes something. `PUT .../steps` requires the same test case write permission (held by `owner`/`admin`/`member`); a `viewer` receives `403 forbidden`.

### Concurrent edits (`currentRevision`)

Every test-case response carries `stepFormat` (`classic` or `gherkin`) and `currentRevision`. The revision starts at `1` and goes up by one each time the case's content is saved: the consolidated `PATCH`, `PUT .../steps`, `PUT` and `PATCH .../custom-field-values`, converting steps to a shared step or detaching one, and an import that replaces the case. It also goes up by one when a shared step the case uses changes what the case shows: retitling it, replacing its items, or deleting it (a case whose item range leaves the change out keeps its revision). Archiving, restoring and cloning leave it unchanged; a clone starts at `1`.

To avoid overwriting someone else's edit, send the `currentRevision` you last read as `expectedRevision`:

- In the JSON body of the consolidated `PATCH`, `PUT .../steps`, `POST .../steps/convert-to-shared` and both custom-field-value endpoints. On the `PATCH`, `expectedRevision` alone is not a save: at least one of `patch`, `steps` or `customFieldValues` is still required.
- As a query parameter on the write without a body: `POST .../steps/{stepUlid}/detach?expectedRevision=4`.

If the case has moved on, the write is refused with `409 conflict` and nothing is written:

```json
{
  "error": {
    "code": "conflict",
    "message": "the test case changed since it was read",
    "details": { "reason": "stale_revision", "currentRevision": 5 }
  }
}
```

Read the case again, reapply your change on top of it and retry. Without `expectedRevision`, the write is applied to whatever is stored, and the last write wins.

Convert and detach can also answer `409 conflict` without `details.reason` when the steps or the shared step changed while the request ran. Nothing is written, and that request can be sent again as is.

Successful writes return the new revision so you can chain saves: the `PATCH` returns the case, and `PUT .../steps`, convert, detach and both custom-field-value endpoints add `currentRevision` to their body.

### Mandatory test case review (`202` and `review_required`)

When a project has test case review turned on and **mandatory** (Pro or Enterprise plan; see
[Test case review settings](https://docs.probara.net/en/developers/api/project-review-settings/)), no write changes a case's
content directly:

- `POST .../test-cases` and the consolidated `PATCH /test-cases/{caseUlid}` send their body for
  review instead. They answer `202 Accepted` with the
  [review request](https://docs.probara.net/en/developers/api/test-case-reviews/) they opened (`kind` `create` or `update`,
  the project's default reviewers, you as author), and the repository is unchanged until it is
  merged. A `PATCH` of a case that already has an open request answers `409 conflict` with
  `details.reason` `review_already_open`.
- `POST /test-cases/{caseUlid}/archive` and `DELETE /test-cases/{caseUlid}` answer `202` with an
  `archive` or `delete` review request instead of archiving or deleting the case, and
  `POST .../test-cases/bulk-archive` answers `202` with `{ created, skipped }`: one `archive` request
  per case, and the cases skipped (an open request, already archived, not in the project), like
  `POST .../test-case-reviews/bulk`. `POST /test-cases/{caseUlid}/unarchive` is never sent to review.
- Every other direct write of a case's content is refused with `409 conflict` and nothing is
  written: `PUT .../steps`, `PUT` and `PATCH .../custom-field-values`, converting steps to a shared
  step, detaching one, restoring a revision, and cloning a case or a suite
  into the project.
- `DELETE /suites/{suiteUlid}` is refused with `409 conflict` (`review_required`) while the suite
  or one of its descendants still holds a case that is not archived, whatever the
  `caseDisposition`; nothing is deleted. Archive those cases through review first. A suite with no
  cases, or only archived ones, is deleted as usual.

```json
{
  "error": {
    "code": "conflict",
    "message": "this project requires changes to test cases to go through review; send them as a review request",
    "details": { "reason": "review_required" }
  }
}
```

Send those changes as a review request instead, or as one consolidated `PATCH`. A
[CSV import](https://docs.probara.net/en/developers/api/import/) opens one review request per case. While review is off or
optional, and on plans without test case review, every write behaves as before.

### Review status (`reviewStatus`)

Every test-case response carries `reviewStatus`: `not_reviewed`, `in_review` or `approved`. A case
starts `not_reviewed` (`approved` when it was created by merging a review request), is `in_review`
while an update request for it is open and `approved` once one is merged; declining a request puts
back the status the case had before. Any direct change to the case — the consolidated `PATCH`,
`PUT .../steps`, custom field values, restoring a revision, an import replacing it — makes it
`not_reviewed` again, and a clone starts `not_reviewed`. Filter by it with
`GET .../test-cases/search?reviewStatus=approved,in_review` or the PQL field `reviewStatus`.

### Version history (`/revisions`)

Every save that moves `currentRevision` also stores that revision of the case. Read them with:

- `GET /api/v1/test-cases/{caseUlid}/revisions` — the stored revisions, newest first, with cursor pagination (`limit`, `cursor`). Each item has `revision`, `source` (`edit`, `import`, `shared_step`, `restore`, `review` or `git_sync`, the [Git sync](#gherkin-sync) rewriting a synced case), `author` (same shape as `updatedBy`), `createdAt` and `isCurrent`; the response also carries the case's `currentRevision`. A case never saved since it was created has an empty list: the case itself is its only version.
- `GET /api/v1/test-cases/{caseUlid}/revisions/{revision}` — one revision with `snapshot`, the case as that save left it: `title`, `description`, `suite`, `milestone`, `automationKey`, `tags`, `customFieldValues` and `steps` (shared steps expanded into the items they showed then). A revision the case has no stored copy of answers `404 not_found`.

Option fields (`select_single`, `radio`, `select_multi`) keep the stored `valueText` (option ULIDs) and add `selectedOptions`, labeled from the field's current options in the request locale. An option deleted since has `"label": null`:

```json
{
  "fieldUlid": "01J...TEAM",
  "name": "Team",
  "type": "select_single",
  "valueText": "01J...FRONTEND",
  "selectedOptions": [{ "ulid": "01J...FRONTEND", "label": null }]
}
```

Each step's `images` array is always `[]`: a step's images are inline in its text, and a revision keeps them through its rich-text fields. Anyone who can read the case can read its history, archived cases included. MCP: `list_test_case_revisions` and `get_test_case_revision`.

An image stays stored while any text or stored revision references it. `GET /api/v1/orgs/{orgUlid}/attachments` lists each content image once, as a `content_image` item. `attachments:bulk-delete` does not accept content images and answers `not_found`: remove an image by removing it from the text that holds it.

Reading revisions and restoring one need a plan that includes version history (Pro and Enterprise). On other plans the three `/revisions` endpoints, and their MCP tools, answer `403 forbidden`; `GET /api/v1/orgs/current` carries `caseHistoryEnabled` so you can check first. Every plan still stores a revision with each save, and `currentRevision` and `expectedRevision` work the same on every plan.

#### Restoring a revision

`POST /api/v1/test-cases/{caseUlid}/revisions/{revision}/restore` brings the case back to that revision. It is a save like any other: it needs the same role as the `PATCH` (owner, admin or member), creates a new revision with `source` `restore`, and records the activity event that save would record, with `restoredFrom`: `test_case.updated`, or `test_case.moved` when the restore changes the suite. The body is optional: a JSON object with an optional `expectedRevision`, and an empty body works like `{}`. A stale `expectedRevision` answers the `409 stale_revision` conflict above and writes nothing.

What comes back:

- Title, description, automation key, tags and steps (with their ULIDs and inline images). If another case now holds the automation key, the restore answers `409 conflict` with `details.reason` `automation_key_taken` and writes nothing.
- The suite and milestone, if they still exist. A deleted suite leaves the case in its current suite; a deleted milestone leaves it without one.
- Custom fields, field by field. A deleted field and a deleted option are skipped. A field the revision had no value for is cleared, unless it is required: then it keeps its current value.
- A shared step that still exists comes back as a reference with its item range, so it shows the shared step's **current** content. A deleted shared step comes back as inline steps with the text and images the revision showed.

The response is the case, as the `PATCH` returns it, plus `restore`:

```json
"restore": {
  "fromRevision": 4,
  "skipped": [
    { "kind": "milestone", "ulid": "01J...SPRINT", "name": "Sprint 12" },
    { "kind": "customFieldOption", "ulid": "01J...FRONTEND", "name": "Team" },
    { "kind": "sharedStepInlined", "ulid": "01J...LOGIN", "name": "Log in" },
    { "kind": "image", "ulid": "01J...SHOT", "name": "Open the cart" }
  ]
}
```

`skipped[].kind` is `suite`, `milestone`, `customField`, `customFieldOption` or `sharedStepInlined`; `name` is the item's name at that revision (for `customFieldOption`, the field's name). Restoring the revision the case is already at answers `422 validation_failed` with `details.reason` `already_current`; a revision with no stored copy answers `404 not_found`. If the deleted shared steps coming back as inline steps would give the case more than 500 steps, the restore answers `422 validation_failed` with `details.reason` `case_step_limit`, `limit` and `cases` (the case, with the `stepCount` it would have), and writes nothing. A synced case cannot be restored to a revision: the request answers `409 test_case_synced_read_only` and writes nothing. MCP: `restore_test_case_revision`.

### Automation key

A test case can carry an `automationKey`: the stable key CI tooling uses to link an automated test to the case, for example `e2e/login.spec.ts > Login > rejects bad password`. A case with a key counts as automated.

- Set it on create (`POST /api/v1/projects/{projectId}/test-cases`, optional `automationKey`) or with `patch.automationKey` on the consolidated `PATCH`; `null` clears it. Every test-case response carries `automationKey` (`null` when unset).
- The key is trimmed and must be 1–1024 characters with no control characters; a blank key is rejected with `422 validation_failed`.
- It is unique per project, archived cases included. A key another case already holds is refused with `409 conflict` and `details.reason` `automation_key_taken`, and nothing is written. The same key may be used in different projects.
- Clones never copy the key, and the web app shows it read-only on the case detail.
- The key holds no parameters. A parameterized case has one key, and its run cases are told apart by their values; see [Parameterized test cases](https://docs.probara.net/en/guides/parameterized-test-cases/).
- On a synced case the key is read-only: it is the key of its scenario, and a save that changes it answers `409 test_case_synced_read_only`.

### Synced cases

Every test-case response carries `source`: `manual` for a case written in Probara, or `git` for a case that the [Gherkin sync](#gherkin-sync) synced from a `.feature` file. A `git` case also carries `sourceFile`: the file's repository-relative `path`, the `line` of its scenario, its `https` `url` when the sync knows one (otherwise `null`), and the `tags` the file gives the case. A `manual` case has `sourceFile: null`.

A `git` case is read-only for its title, description, steps and step format, `preconditions` value, automation key, and the tags its file gives it. A save that changes one of them, or drops one of those tags, answers `409 test_case_synced_read_only` and writes nothing. The same code refuses a revision restore, a review request (single or bulk), and a conversion of its steps into a shared step. Its assignee, priority, custom fields other than `preconditions`, defects, links, comments, plan and run membership, suite and the tags you add stay writable.

### Created and updated by

Every test-case response carries `author` — who created the case — and `updatedBy` — who made the change that last set `updatedAt`. Each is a member, `{ "kind": "user", "ulid", "displayName", "avatarKey" }`, or an API token, `{ "kind": "api_token", "ulid", "name", "app" }`, the same shape as a run's `author`: `app` is `"junit"`, `"playwright"`, `"jest"`, `"cypress"`, `"vitest"` or `"cucumber"` for an organization app token and `null` for a personal API token, which is named as itself rather than as its owner.

- Creating a case — through this API, a CI report, an import, a clone or a Git sync — sets both to the caller. A clone is authored by whoever clones it, not by the source case's author.
- Every change that bumps `updatedAt` sets `updatedBy` and leaves `author` alone: the consolidated `PATCH`, archive, restore, bulk archive, a CI report linking an existing case to its automated test, an import replacing a case, a Git sync, and deleting a suite that moves or archives its cases.
- Both are `null` for a case created before Probara recorded authorship, and for a member or token that has been erased.

```json
{
  "author": { "kind": "user", "ulid": "01J...ANA", "displayName": "Ana Pérez", "avatarKey": null },
  "updatedBy": { "kind": "api_token", "ulid": "01J...TOKEN", "name": "Nightly", "app": "jest" }
}
```

### Case step text and images

Each classic step has three rich-text fields, `action`, `data` and `expected`, with the documents `actionDocument`, `dataDocument` and `expectedDocument`. Images go inline in these fields, as described in [Rich text fields](#rich-text-fields). Gherkin steps keep plain text and have no documents. Steps have no image gallery: their `attachments` array is always `[]`, and naming one is refused.

On a synced case, step text is read-only: a save that changes it answers `409 test_case_synced_read_only`.

- `PUT /api/v1/test-cases/{caseUlid}/custom-field-values` atomically replaces the case's value set:

```bash
curl -sS -X PUT \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  -H "content-type: application/json" \
  -d '{
    "values": [
      { "fieldUlid": "01J...PRIORITY", "value": "01J...OPTION_HIGH" },
      { "fieldUlid": "01J...IS_FLAKY", "value": true },
      { "fieldUlid": "01J...PRECONDITIONS", "value": "Have a user logged in" }
    ]
  }' \
  "https://app.probara.net/api/v1/test-cases/01J.../custom-field-values"
```

For `checkbox` fields, the JSON boolean form (`true` / `false`) matches what `GET` returns; the legacy string form (`"true"` / `"false"`) is still accepted.

Empty / `null` values delete the corresponding row. The endpoint returns `200` with the full embedded array. On a synced case the `preconditions` value is read-only: a save that changes it answers `409 test_case_synced_read_only`. Validation rules per field type live in the OpenAPI schema (`PutCustomFieldValuesInput`).

To discover the system field ULIDs (`priority`, `severity`, etc.) for an organization, call `GET /api/v1/orgs/{orgUlid}/custom-fields`. Add `?projectUlid={projectUlid}` to filter to definitions visible for a specific project (useful when building test-case forms).

### Update a single custom-field value

`PATCH /api/v1/test-cases/{caseUlid}/custom-field-values/{fieldUlid}` upserts or clears **one** field without replacing the rest of the case's values. The body is `{ "value": <unknown> }`, plus an optional [`expectedRevision`](#concurrent-edits-currentrevision); no other keys. The response is `200` with the same full `customFieldValues` embedded array as `PUT`.

PATCH only enforces required on the **targeted** field; other required fields' state is not inspected. Use `PUT` for atomic full-form replacement when every visible required field must be present in the body.

```bash
curl -sS -X PATCH \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  -H "content-type: application/json" \
  -d '{ "value": "01J...OPTION_HIGH" }' \
  "https://app.probara.net/api/v1/test-cases/01J.../custom-field-values/01J...SEVERITY"
```

Empty / `null` values delete the row when the field is not required. Clearing a required field returns `422 validation_failed` with `details` referencing `path: ["value"]`.

### Update a custom field

`PATCH /api/v1/orgs/{orgUlid}/custom-fields/{fieldUlid}` updates definition metadata and, for option-based types, the `options` list. Each entry in `options` MAY include the existing option's `ulid`. When `ulid` matches a row already stored for the field, PATCH preserves that ULID and updates `name`, `icon`, `color`, and `position` in place. Entries without `ulid` are inserted as new options with a freshly generated ULID. Rows whose `ulid` is absent from the patch are deleted (system fields still reject removing seed options that carry a `systemKey`).

Because `select_single`, `select_multi`, and `radio` values store option ULIDs in `custom_field_values`, always echo back the `ulid` returned by `GET` when you mean to update an existing option. Omitting `ulid` on a row that already exists creates a new option and leaves any stored values pointing at the old ULID orphaned.

### Required test-run fields require a default

`entity` on `POST /api/v1/orgs/{orgUlid}/custom-fields` and `PATCH /api/v1/orgs/{orgUlid}/custom-fields/{fieldUlid}` accepts `test_case`, `test_run`, and `defect`. A definition with `entity: "test_run"` and `isRequired: true` MUST carry a non-empty `defaultValue`. Violating this on either endpoint returns `422 validation_failed` with `details.field = "defaultValue"`, and creates or modifies no definition row. The invariant is evaluated against the definition's effective state after the write, so marking an existing `test_run` field required with no stored default is rejected on `PATCH` exactly as it is on `POST`; setting `isRequired` and a non-empty `defaultValue` together in the same request is accepted. `test_case` and `defect` fields are exempt and may stay required with no default, as before.

### Reset a system field to seed defaults

Owners and admins may restore a **system** custom field definition to its seeded state:

```bash
curl -sS -X POST \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  -H "X-Organization-Id: $ORG_ULID" \
  -H "content-type: application/json" \
  -d '{}' \
  "https://app.probara.net/api/v1/orgs/$ORG_ULID/custom-fields/$FIELD_ULID/reset"
```

- **200** — Returns `{ field }` with seed title, cleared placeholder, seed option names/icons/colors, and stable option ULIDs (matched by `systemKey`). User-added options (`systemKey: null`) are deleted.
- **400 `not_system_field`** — The target is a custom (non-system) field.
- **403** — Caller is not an owner/admin.
- **404** — Unknown field, unknown org, or cross-tenant access.

The operation is **idempotent** on an already-pristine field. **Project scope** (`allProjects`, `projectUlids`) is never changed.

**Value cascade:** When user-added options are removed, stored test-case values that referenced those option ULIDs are updated in the same request: `select_single` / `radio` values fall back to the seed default ULID when configured, otherwise the value row is deleted; `select_multi` arrays drop removed ULIDs and apply the seed default array when the result would be empty. Non-option field values (paragraph, checkbox, etc.) are not modified.

### Delete a custom field

Owners and admins may delete a **custom** field. Check first what the delete would erase:

```bash
curl -sS \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  -H "X-Organization-Id: $ORG_ULID" \
  "https://app.probara.net/api/v1/orgs/$ORG_ULID/custom-fields/$FIELD_ULID/usage"
```

It returns `{ valueCount, projectCount }`: the values stored for the field across test cases, test runs, and defects, and how many projects hold them.

`DELETE /api/v1/orgs/{orgUlid}/custom-fields/{fieldUlid}` deletes the definition. A field that still holds values is refused unless you add `?deleteValues=true`, which permanently deletes every stored value together with the field.

- **204** — The field (and, with `deleteValues=true`, its values) was deleted.
- **409 `field_has_values`** — The field holds values and `deleteValues=true` was not sent.
- **403 `system_field`** — System fields cannot be deleted, with or without `deleteValues`.
- **404** — Unknown field, unknown org, or cross-tenant access.

## Parameterized test cases

A test case holds `parameters` (single, group, and shared entries, in order) and `excludedCombinations`. Case responses also carry `combinationCount`, the combinations the parameters produce before exclusions: 1 for a case without parameters. A run creates one run case for each combination that is not excluded. The rules and limits are in [Parameterized test cases](https://docs.probara.net/en/guides/parameterized-test-cases/); a value over a limit is refused with `422 validation_failed`.

| Field                  | Where                                  | Meaning                                                                                                                                                                                                        |
| ---------------------- | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `parameters`           | Case create and update; case responses | The parameters in order. A `single` has `name` and `values`; a `group` has `name`, `columns`, and `rows`; a `shared` entry has `sharedParameterUlid`. Responses resolve a shared entry to the values it holds. |
| `excludedCombinations` | Case create and update; case responses | Each excluded combination is a list of `{ name, value }` pairs, with a value for every parameter of the case. A write replaces the whole set; omitting the field keeps it.                                     |
| `combinationCount`     | Case responses                         | The combinations before exclusions.                                                                                                                                                                            |

Shared parameters live under `/api/v1/projects/{projectId}/shared-parameters`:

| Method and path                                                     | Purpose                                                                                                                                                                                                                                                                   |
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET` `/shared-parameters`                                          | Lists the shared parameters the project can use: its own, and the organization ones available to it.                                                                                                                                                                      |
| `POST` `/shared-parameters`                                         | Creates one. `scope` is `project` or `organization`. An organization parameter may be limited with `projectUlids`, and needs the organization shared steps entitlement.                                                                                                   |
| `GET`, `PATCH`, `DELETE` `/shared-parameters/{sharedParameterUlid}` | Reads, changes, or deletes one. The kind and the scope are fixed at creation. A change reaches every case that uses the parameter. `DELETE` takes `mode=convert_to_local` (the default: each case keeps its own copy) or `mode=remove` (the reference leaves every case). |
| `GET` `/shared-parameters/{sharedParameterUlid}/usage`              | Lists the projects whose cases use the parameter, with the case count in each.                                                                                                                                                                                            |

Two endpoints work on a test case:

| Method and path                                                                          | Purpose                                                                                                                                                            |
| ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `GET` `/api/v1/projects/{projectId}/test-cases/{caseUlid}/combinations`                  | The combination preview: `combinationCount`, the `combinations` left after exclusions, and the `excludedCombinations`.                                             |
| `POST` `/api/v1/projects/{projectId}/test-cases/{caseUlid}/parameters/{position}/detach` | Converts the shared entry at `position` into a local copy of its values. Returns the case's parameters, exclusions, combination count, and the revision it stored. |

Run responses carry the same combinations:

- A run case has `parameters`, the combination it is (`[]` for a case without parameters). Its detail has `siblings`, the other run cases of the same case in the run, and `reportedParameters`, the parameters its current attempt reported. `reportedParameters` was the detail's `parameters` before this change.
- `POST /api/v1/runs/{runUlid}/cases/{runCaseUlid}/apply-latest` refuses with `409 conflict` and `details.reason: combination_removed` when the latest version no longer produces the run case's combination. The bulk apply lists the same reason under `skipped`.
- A result has `runCaseUlid`, the run case it belongs to (`null` once that run case was removed), and `parameters`, its combination.
- `GET /api/v1/runs/{runUlid}/case-keys` lists each run case's `caseDisplayId`, `automationKey`, and `parameters`, one entry per instance.
- The public run share returns the authenticated run-case shape, so its run cases carry `parameters` too.

## Test case attachments

A test case's Attachments section lists two kinds of entries: the images its texts reference, and loose files uploaded to the case. Both come from `GET /api/v1/test-cases/{caseUlid}/attachments`.

Loose files follow the [defect evidence](https://docs.probara.net/en/developers/api/defects/) rules: any type is accepted except executables and scripts, empty files are refused, PNG, JPEG, and WebP images are converted to WebP with a thumbnail, and every other file is stored as sent. A case holds at most **20** loose files, and a request carries up to **20** `file` parts. Uploads charge the organization `case_attachment` storage counter.

Viewers may list attachments; only **members and above** may upload or delete.

### List

`GET /api/v1/test-cases/{caseUlid}/attachments`

Returns `{ attachments: [...] }`. The images come first, in text order: the description, then the paragraph custom fields, then the steps by position. An image has `kind: "image"` and its `ulid`, `mime`, dimensions, `byteSize`, `url`, `thumbUrl`, and `createdAt`, plus a `field` that says where it sits:

- `{ "kind": "description" }` for the description.
- `{ "kind": "custom_field", "fieldUlid": "…", "name": "…" }` for a paragraph custom field.
- `{ "kind": "step", "stepUlid": "…", "position": 2, "part": "expected" }` for a step's action, data, or expected text. `position` is the step's 1-based position.

Loose files follow, oldest first. Each has `kind: "file"` and the fields of a defect attachment: `ulid`, `mime`, dimensions, `byteSize`, `originalFilename`, `uploadedBy`, `url`, `thumbUrl`, and `createdAt`.

### Upload

`POST /api/v1/test-cases/{caseUlid}/attachments`

`multipart/form-data` with one or more `file` parts. Returns `201` with `{ attachments: [...] }`, one loose file per part. A blocked type, an empty file, an oversize file, or a request that would take the case past 20 loose files returns `422 validation_failed` and stores nothing. An upload that would exceed the organization storage quota returns `413 storage_quota_exceeded`. Emits `test_case.attachment_added`.

This is a direct upload, not a stage, and it ignores the [`Idempotency-Key`](https://docs.probara.net/en/developers/api/idempotency/) header. A retried upload can store the same files twice: after a timeout or another ambiguous failure, list the case's attachments before you retry.

### Delete

`DELETE /api/v1/test-cases/{caseUlid}/attachments/{attachmentUlid}`

Returns `204`. Removes the loose file, its stored object and its thumbnail, and refunds its storage bytes. Emits `test_case.attachment_deleted`. The ULID of an inline image returns `404 not_found`: an image leaves the case when you remove it from its text.

### Loose files and the case lifecycle

- Revisions do not hold loose files, so restoring a revision leaves them as they are.
- Cloning a case does not copy its loose files. The copy starts with none.
- Archiving a case keeps its loose files. Deleting a suite with `caseDisposition: "deleteCases"` archives its cases rather than deleting them, so their files stay with them.
- Deleting a case, or a project, removes its loose files with their bytes and stored objects.

## Projects

`PATCH /api/v1/projects/{projectId}` requires the project management permission (held by **`owner`**/**`admin`**). **`member`** and **`viewer`** receive **`403 forbidden`** with `error.code` `forbidden`. Creating a project (`POST /api/v1/projects`) requires the same project management permission: **only `owner`/`admin` may create a project** — a `member` or `viewer` now also receives `403 forbidden`. `GET` on `/api/v1/projects` and `GET /api/v1/projects/{projectId}` remain unchanged for authorized org members.

`GET /api/v1/projects/{projectId}` includes `avatarKey` (`string | null`) and `avatarVersion` (`number`). When `avatarKey` is non-null, the image is served publicly at `GET /__assets/{avatarKey}?v={avatarVersion}` (no session).

| Method   | Path                                  | Body                                                             | Response                             |
| -------- | ------------------------------------- | ---------------------------------------------------------------- | ------------------------------------ |
| `POST`   | `/api/v1/projects/{projectId}/avatar` | `multipart/form-data` with one `file` (PNG/JPEG/WebP, max 5 MiB) | `{ avatarKey, avatarVersion }`       |
| `DELETE` | `/api/v1/projects/{projectId}/avatar` | —                                                                | `{ avatarKey: null, avatarVersion }` |

`POST`/`DELETE` on `/api/v1/projects/{projectId}/avatar` require **`owner`** or **`admin`**. Images are normalized server-side to 256×256 WebP under `avatars/projects/{projectUlid}.webp`.

### Access control

`GET`/`PATCH /api/v1/projects/{projectId}/access` read and change a project's public/private access mode
and owner — a **dedicated** resource; access state is never added to the general `GET /projects/{projectId}`
response. `PATCH` requires organization **`owner`**/**`admin`** or the project's **current owner** — a
member with only read access (a `granted` allowlist row) is not automatically authorized to toggle it.

`GET`/`POST`/`DELETE /api/v1/projects/{projectId}/access/members` list, grant, and revoke individual
members on a private project's allowlist, without ever changing the project's mode — only
`PATCH /access { mode }` does that. Same authorization rule as the toggle. Removing the **last**
remaining grant now succeeds (`204`): the allowlist may legitimately be empty on a private project;
publishing a project is always an explicit mode change through `PATCH /access { mode }`, never a side
effect of revoking the last person on the allowlist.

See [Project access](https://docs.probara.net/en/developers/api/project-access/) for the full request/response shapes, the
toggle-seeding rules, and one documented limitation: API tokens are never evaluated against the
allowlist. Enforcement itself follows the data, not the URL shape — routes that resolve their project
transitively from an entity ULID are gated identically to `/projects/{projectId}/*`.

A [user group](https://docs.probara.net/en/developers/api/user-groups/) can also be assigned to a project's allowlist,
granting every current and future group member access in one operation:

| Method   | Path                                                         | Notes                                                                                                                                   |
| -------- | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
| `GET`    | `/api/v1/projects/{projectId}/access/groups`                 | List the groups assigned to the project's allowlist                                                                                     |
| `POST`   | `/api/v1/projects/{projectId}/access/groups`                 | Assign a group (`{ userGroupUlid }`); succeeds on a public project — the assignment is dormant until private                            |
| `DELETE` | `/api/v1/projects/{projectId}/access/groups/{userGroupUlid}` | Unassign a group; never `409`, even for the last remaining group                                                                        |
| `GET`    | `/api/v1/groups/{userGroupUlid}/projects`                    | The reverse view: the projects a given group is assigned to (see below)                                                                 |
| `POST`   | `/api/v1/groups/{userGroupUlid}/projects`                    | Assign the group to one or many projects in one request (`{ projectUlids: string[] }`); no org gate of its own — authorized per project |

Same authorization rule as the member allowlist (organization `owner`/`admin`, or the project's
current owner). Unlike the individual family, assigning a group is allowed on a **public** project:
`access_mode` is a stored fact, never inferred from an assignment, so the grant stays dormant and
survives every later mode toggle in either direction. There is no group-level revoke — the
individual allowlist's own `revoked` row always outranks a group grant.

`GET /api/v1/groups/{userGroupUlid}/projects` is read-open like the other group `GET`s (see
[User groups](https://docs.probara.net/en/developers/api/user-groups/)), so a plain organization member can call it — but
unlike the group's own `projectCount`, this listing filters out any project the caller cannot
otherwise discover: a private project the group is assigned to is silently omitted from `items`
and `total` for that caller, while an organization `owner`/`admin` sees every assignment.

### Test-run settings

`GET`/`PATCH /api/v1/projects/{projectId}/run-settings` read and change the twelve settings that
govern marking, closed-run writes, and run-creation defaults — resolved through
`project → organization → code default` for eleven of the twelve keys.
`GET`/`PATCH /api/v1/orgs/{orgUlid}/run-settings` read and change the organization-wide defaults
for those same eleven keys. `PATCH` on the project endpoint requires organization
`owner`/`admin` or the project's current owner; `PATCH` on the organization endpoint requires the
`org-settings.manage` permission. See
[Test-run settings](https://docs.probara.net/en/developers/api/project-run-settings/) for the full vocabulary, the
resolution/`source` contract, and the one documented client-only gap
(`requireCommentOnNegativeResult`).

### Test case review settings

`GET`/`PATCH /api/v1/projects/{projectId}/review-settings` read and change whether the project's
test case changes go through review, and how: `mandatory`, `allowSelfMerge`, `approvalsRequired`,
`autoMerge`, `onlyApprovedInRuns` and `defaultReviewerUserIds`. Same read and write rules as the test-run settings. Pro or Enterprise
plan only: on other plans both answer `403 forbidden`. See
[Test case review settings](https://docs.probara.net/en/developers/api/project-review-settings/).

### Test case review requests

`POST /api/v1/projects/{projectId}/test-case-reviews` sends a new case or a change to an existing
case for review instead of saving it; `GET .../test-case-reviews/{reviewUlid}` reads one request.
`GET .../test-case-reviews` lists them, `GET .../{reviewUlid}/versions` answers the proposed version
next to the case as it is now, and `approve`, `unapprove`, `request-changes`, `PATCH` (edit), `reviewers`,
`decline` and `merge` act on an open request; `.../{reviewUlid}/comments` holds its discussion.
`POST .../test-case-reviews/bulk` sends cases for review as they are, and
`POST .../test-case-reviews/bulk-decision` decides on several requests at once.
See [Test case review requests](https://docs.probara.net/en/developers/api/test-case-reviews/). A synced case takes no review request: creating one answers `409 test_case_synced_read_only`, and a bulk request that names one is refused whole.

### List: optional stats and member filter

`GET /api/v1/projects` accepts two optional, additive query parameters. Both are
opt-in: a request **without** them returns exactly today's flat item shape, and
the `POST`/`GET /api/v1/projects/{projectId}` response shapes are **unchanged**.

| Query param     | Notes                                                                                                                                                                                                                                                                   |
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `include=stats` | Embeds an optional `stats` object, a `team` array, and a `teamCount` integer on **each list item**. The only accepted value is `stats`.                                                                                                                                 |
| `memberUlid`    | Comma-separated set of member ULIDs. Filters the page (and `total`) to projects whose team includes any of the listed members. Unknown ULIDs (not active members of your org) are dropped silently. Independent of `include` — you may filter without requesting stats. |

When `include=stats` is present, each item additionally carries:

| Field                     | Type    | Meaning                                                                                                                                                                                      |
| ------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `stats.cases`             | integer | Non-archived test cases                                                                                                                                                                      |
| `stats.runs`              | integer | All test runs                                                                                                                                                                                |
| `stats.runsInProgress`    | integer | Test runs with `state = open`                                                                                                                                                                |
| `stats.defectsUnresolved` | integer | Defects with status `open` or `in_progress`                                                                                                                                                  |
| `stats.milestones`        | integer | Live (non-deleted) milestones                                                                                                                                                                |
| `team`                    | array   | Lean, avatar-ready subset of the project's team — at most 6 members, ordered by membership join time ascending. Each entry is `{ userUlid, firstName, lastName, avatarKey, avatarVersion }`. |
| `teamCount`               | integer | Full team size for the project (drives a `+N` overflow indicator).                                                                                                                           |

A project with no matching rows reports `0` for each metric (never a missing
field). All counts are tenant-isolated — they only ever include your own
organization's rows.

```bash
curl -sS \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  "https://app.probara.net/api/v1/projects?include=stats&memberUlid=01J...,01K..."
```

> **`team`/`teamCount`/`memberUlid` are mode-aware.** For a **public** project the team is the active
> organization membership (minus anyone individually revoked) — the original opt-out behavior, unchanged.
> For a **private** project the team is exactly the members holding a `granted` allowlist row, intersected
> with active organization membership; members who reach the project only through the organization
> `owner`/`admin` role bypass hold no grant and are correctly **absent** from the team, even though they
> do appear in [the project's access member list](https://docs.probara.net/en/developers/api/project-access/). `memberUlid`
> narrows against this same mode-aware set, so it has real effect on both public and private projects.

### List: presence and state filters

`GET /api/v1/projects` also accepts four optional, additive **presence/state**
filters that narrow which projects appear. They are applied **server-side**, so
the returned `total` and pagination always reflect the filtered set. Each filter
takes a **comma-separated list of option tokens** (you may also repeat the
parameter, e.g. `runs=without&runs=active`). Within one filter the selected
options are combined with **OR**; across different filters they combine with
**AND**. They stack with `q` and `memberUlid`, and a request without any of them
is unchanged. **Unknown tokens are dropped silently** (no `422`); a filter whose
tokens are all unknown is treated as absent.

Each filter mirrors the corresponding metric definition exactly, so filtering
agrees with the counts you see under `include=stats`.

| Query param  | Options   | Meaning                                                                           |
| ------------ | --------- | --------------------------------------------------------------------------------- |
| `runs`       | `without` | Projects with **no** test runs                                                    |
|              | `active`  | Projects with at least one **open** run (`state = open`)                          |
|              | `any`     | Projects with at least one run (any state)                                        |
| `defects`    | `has`     | Projects with at least one **unresolved** defect (status `open` or `in_progress`) |
|              | `without` | Projects with **no** unresolved defect (resolved-only or none)                    |
| `milestones` | `has`     | Projects with at least one **live** (non-deleted) milestone                       |
|              | `without` | Projects with no live milestone                                                   |
| `cases`      | `has`     | Projects with at least one **active** (non-archived) test case                    |
|              | `without` | Projects with no active case                                                      |

> **`defects` means unresolved.** The `defects` filter only ever considers
> defects whose status is `open` or `in_progress` — the same definition as
> `stats.defectsUnresolved`. Resolved or closed defects never make a project
> match `defects=has`.

All four filters are tenant-isolated: they only ever consider rows in your own
organization.

```bash
# Projects that have an open run AND no unresolved defects, named like "mobile":
curl -sS \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  "https://app.probara.net/api/v1/projects?q=mobile&runs=active&defects=without"

# OR within a filter: projects with no runs OR an active run:
curl -sS \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  "https://app.probara.net/api/v1/projects?runs=without,active"
```

## Test runs & results

A run snapshots the cases selected into it. Create a run with the cases to
execute, then read and mutate its cases:

| Method          | Path                                                          | Notes                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| --------------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST`          | `/api/v1/projects/{projectUlid}/runs`                         | Body `{ name, description?, defaultAssigneeUlid?, environmentId?, environment?, caseUlids?[], planUlid?, plan?, milestoneId?, milestone?, configurationUlids?[], configurations?[], customFieldValues?[], source?, automated? }` (`source`: see [Run source](#run-source); `environment`, `milestone`, `plan` and `configurations`: see [Run references by name](#run-references-by-name)). Snapshots title + steps of each case. `caseUlids` (1–5000 when present) is required unless `planUlid` or `plan` is supplied (the run's cases and per-case assignees seed from the plan's current selection) or `automated: true` is sent (allows a run with no cases, for CI tools that report into it later; cases from `caseUlids` or `planUlid` are still added; request-only, not stored). One request creates exactly one run — a `planUlid` never fans out into more than one.                                                                                                                                  |
| `GET`           | `/api/v1/projects/{projectUlid}/runs`                         | Each item carries progress (`total`, `executed`, `passRate`), an exact 5-band `counts` object, timing aggregates (`firstResultAt`, `lastResultAt`, `totalDurationMs`), and a discriminated `author` (see below).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `GET`           | `/api/v1/projects/{projectUlid}/runs/environments`            | **Deprecated.** Returns `{ items: string[] }` with distinct non-empty free-text `environment` values for the project, sorted ascending. Retained for back-compat; prefer filtering by `environmentId` ULID instead.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `GET`           | `/api/v1/projects/{projectId}/runs/by-display-id/{displayId}` | Resolves a run by its readable ID `R-{n}`; `{projectId}` is the project code. Same body as `GET /api/v1/runs/{runUlid}`. See [Display ID lookups](#display-id-lookups).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `GET` / `PATCH` | `/api/v1/runs/{runUlid}`                                      | `PATCH` updates metadata only (`name`, `description`, `defaultAssigneeUlid`, `environmentId`, `milestoneId`, `configurationUlids`); the aggregated `status` is derived, never patched. Allowed on a closed, non-aborted run; returns `409 conflict` only when the run is **aborted**.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `POST`          | `/api/v1/runs/{runUlid}/close`                                | Completes the run early (`state = closed`) while untested cases remain. While the project's `autoCompleteRun` [run setting](https://docs.probara.net/en/developers/api/project-run-settings/) is on (the default), a run also closes itself once every case has a result recorded through the run-case endpoints. A CI report (`POST /api/v1/projects/{projectId}/reports`) never closes the run on its own: send `options.close: true` or call `close`. Calling `close` on an already-closed or aborted run returns `409 conflict` (the latch is one-way); an unknown run returns `404`.                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `POST`          | `/api/v1/projects/{projectUlid}/runs/{runUlid}/clone`         | "Run again": creates a new **open** run from a closed source run (completed or aborted). Body `{ title: string, cloneAssignees?: boolean, statusFilter?: TestOutcomeStatus[] }` — `title` is required; `statusFilter` selects which source cases carry over (omitted/empty = every case). Generalizes the former `rerun-failed` path, which is removed.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `GET`           | `/api/v1/runs/{runUlid}/case-keys`                            | The run's cases as `{ caseDisplayId, automationKey }`, cursor-paged; readable by an app token (see [Run cases](https://docs.probara.net/en/developers/api/run-cases/#list-a-runs-case-keys)).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `GET` / `POST`  | `/api/v1/runs/{runUlid}/cases`                                | List run cases; `POST` adds (and snapshots) more cases. Each case carries `executedByApiToken`, the API token that executed its current attempt (display only, never the assignee; see [Run cases](https://docs.probara.net/en/developers/api/run-cases/#executedbyapitoken-is-not-an-assignee)).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `POST`          | `/api/v1/runs/{runUlid}/cases/bulk-mark`                      | Body `{ caseUlids: string[1..500], status }` (`passed` \| `failed` \| `skipped` \| `blocked`). One atomic transaction; one `run.cases_bulk_marked` audit event; returns `{ affected, unchanged }`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `POST`          | `/api/v1/runs/{runUlid}/cases/bulk-assign`                    | Body `{ caseUlids, assigneeUlid: string \| null }`. Same atomicity and response shape; emits `run.cases_bulk_assigned`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `POST`          | `/api/v1/runs/{runUlid}/cases/bulk-retry`                     | Body `{ caseUlids }`. Resets selected cases (and their steps) to `untested`; emits `run.cases_bulk_retried`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `POST`          | `/api/v1/runs/{runUlid}/cases/bulk-remove`                    | Body `{ caseUlids }`. Removes cases from the run with pre-deletion snapshots; emits `run.cases_bulk_removed`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `GET` / `PATCH` | `/api/v1/runs/{runUlid}/cases/{runCaseUlid}`                  | `PATCH` sets `assigneeUlid` / `status` / `elapsedMs`. A status change marks every step and appends a result row. The `PATCH` response includes nullable `resultUlid` (the appended attempt when `status` changed; `null` for assignee/elapsed-only patches). `GET` detail adds `linkedDefects[]` — distinct defects linked via any attempt on the case (`ulid`, `defectNumber`, `title`, `status`, and `assigneeUlid`, which is `null` when the defect is unassigned) — and the current attempt's reported `parameters`, `resultSteps` and `links` (see [Run cases](https://docs.probara.net/en/developers/api/run-cases/#get-a-case-of-a-run)). `PATCH` rejects with `403 run_case_assignee_locked` when the project's `assigneeResultLock` setting is on and the caller is neither the assignee nor exempt (no API-token exemption), and with `422 validation_failed` (`details.code: "elapsed_ms_required"`) when `timeTracking` is `required` and a terminal mark omits `elapsedMs` — see [Run cases](https://docs.probara.net/en/developers/api/run-cases/). |
| `POST`          | `/api/v1/runs/{runUlid}/cases/{runCaseUlid}/open`             | Auto-assigns the acting user when unassigned, never in a run an app token (a CI reporter) created, nor when an API token executed the current attempt (see [Run cases](https://docs.probara.net/en/developers/api/run-cases/#opening-a-case-assigns-only-an-empty-slot)).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `PATCH`         | `/api/v1/runs/{runUlid}/cases/{runCaseUlid}/steps/{stepUlid}` | Marks one step. The case keeps its own status: set it with a result.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `GET`           | `/api/v1/runs/{runUlid}/results`                              | Append-only marking history; a case may appear multiple times, ordered by `executedAt`. Each row names who executed it: `executedByUlid` (a member) and `executedByApiToken` (`{ ulid, name, app }` for a result an API token wrote, else `null`; see [Who executed it](https://docs.probara.net/en/developers/api/run-result-snapshots/#who-executed-it)).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |

All mutating endpoints require role **`member`** or above (`viewer` → `403 forbidden`) and emit audit events. An **aborted** run always rejects these mutations with `409 conflict`. A closed, non-aborted run accepts them by default (`allowResultsInClosedRuns` defaults `true`); a project or organization that sets that [test-run setting](https://docs.probara.net/en/developers/api/project-run-settings/) to `false` restores the older behavior, rejecting them on a closed run too.

An automated run is CI reporting, so creating one (`automated: true`) needs a paid plan: on an organization without one it answers `403 forbidden` for every credential, while a manual run is still created. An [app token](https://docs.probara.net/en/developers/authentication/#app-tokens) may only create automated runs.

### Only approved cases in runs

When a 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 name in `caseUlids` (create, add cases, reconcile) are
refused as a whole with `422 validation_failed`, `details.reason` `cases_not_approved` and
`details.notApprovedCaseUlids` listing them. Cases a run takes in on its own — a plan's selection
when `caseUlids` is omitted, or the source run of a clone — are filtered instead, and the answer
carries `notApprovedSkippedCount`. See
[Run cases](https://docs.probara.net/en/developers/api/run-cases/#only-approved-cases). CI reports are not affected.

### Environment linkage on runs

Runs can be linked to a structured [Environment](https://docs.probara.net/en/developers/api/environments/) entity managed by the project.

**Create (`POST /api/v1/projects/{projectUlid}/runs`)**

| Field           | Notes                                                                                                                                                                                                                                                                              |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `environmentId` | Optional ULID of an environment that belongs to the run's project. Returns `404 not_found` if the ULID does not resolve to a live environment in the project. Omit or pass `null` to leave unlinked.                                                                               |
| `environment`   | The environment by name, for CI tools. When it is a non-empty string, the server finds or creates an environment whose slug matches the slugified value and links the run to it. Giving both `environmentId` and `environment` answers `422 validation_failed` and creates no run. |

**Update (`PATCH /api/v1/runs/{runUlid}`)**

| Field           | Notes                                                                                                                                                                        |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `environmentId` | Optional ULID to change or link the run's environment. Pass `null` to unlink. Returns `404 not_found` for unknown ULIDs. Free-text `environment` is not accepted on `PATCH`. |

**Response fields added to `RunResponse`**

| Field             | Type                           | Notes                                                                                                            |
| ----------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------- |
| `environmentId`   | string (ULID) \| null          | ULID of the linked environment, or `null` when not linked.                                                       |
| `environment`     | `{ ulid, name, slug }` \| null | Summary of the linked environment object, or `null` when not linked.                                             |
| `environmentName` | string \| null                 | **Deprecated.** The raw free-text value of the legacy `environment` TEXT column. Use `environment.name` instead. |

### Run references by name

An [app token](https://docs.probara.net/en/developers/authentication/#app-tokens) reads nothing but a run's case keys, so a CI tool cannot
look up the ULID of a milestone, a test plan or a configuration. `POST /api/v1/projects/{projectId}/runs`
and the run a [report](#reports) creates therefore take each reference in one of two forms — except
the plan, which a report's new run takes by name only (`plan`):

| ULID form                    | Name form        | The name form matches                                                                                                                                                                          |
| ---------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `environmentId`              | `environment`    | The slug of a live environment of the project; a new environment is created when none matches (see [Environment linkage on runs](#environment-linkage-on-runs)).                               |
| `milestoneId`                | `milestone`      | A display id `M-<n>`, or else the exact, case-sensitive name of a milestone of the project (1–255 characters).                                                                                 |
| `planUlid` (run create only) | `plan`           | A display id `PLAN-<n>`, or else the exact, case-sensitive name of a test plan of the project (1–200 characters). Same effect as `planUlid`: the run is seeded with the plan's selected cases. |
| `configurationUlids`         | `configurations` | `[{ "group": "Browser", "name": "Chrome" }]`: the exact, case-sensitive name of a configuration group of the project and of a value in it. At most 20 entries, each group at most once.        |

```json
{
  "name": "Nightly",
  "automated": true,
  "description": "Nightly regression on staging",
  "environment": "staging",
  "milestone": "M-3",
  "plan": "Checkout regression",
  "configurations": [{ "group": "Browser", "name": "Chrome" }]
}
```

- A value shaped like a display id (`M-3`, `PLAN-2`) is only ever read as one: it never falls back
  to a name.
- Deleted milestones, plans, configuration groups and values never match.
- An unknown milestone, plan or configuration answers `422 validation_failed` with
  `details: { "field": "<milestone | plan | configurations>", "code": "unknown_reference" }`, and
  nothing is written — not even the environment the same request would have created.
- A plan whose selection is empty answers `422 validation_failed` with
  `details: { "field": "plan", "code": "empty_selection" }` when it was given as `plan`
  (`"field": "caseUlids"` when it was given as `planUlid`), and nothing is written — not even the
  environment the same request would have created. No refused run create writes the environment
  it names.
- Giving both forms of one reference (for example `milestoneId` and `milestone`) answers
  `422 validation_failed`; a `null` counts as not given.
- A report's new run takes the plan as `plan` only: a `planUlid` in it is an unknown field and
  answers `422 validation_failed`. It takes `description` (at most 2000 characters) too, like run
  create.

### Run list filters

`GET /api/v1/projects/{projectId}/runs` accepts page-based pagination (`page`, `pageSize`) plus these server-side filters. Filters compose with AND semantics and `total` reflects the filtered population:

| Parameter      | Notes                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `q`            | Case-insensitive substring search over run name and environment                                                                                                                                                                                                                                                                                                                                                                             |
| `status`       | Comma-separated projected status: `in_progress`, `passed`, `failed`                                                                                                                                                                                                                                                                                                                                                                         |
| `env`          | Repeatable value. Repeat `env` for OR semantics. Pass the environment **ULID** to filter by linked environment entity; pass a free-text string to match the legacy `environment` text column. ULID and free-text values compose with OR when mixed. Values may contain commas, so do not comma-split.                                                                                                                                       |
| `authorUlid`   | User ULID of the run author. API-token authored runs do not match user authors.                                                                                                                                                                                                                                                                                                                                                             |
| `assigneeUlid` | Default-assignee user ULID, or `empty` for runs without a default assignee.                                                                                                                                                                                                                                                                                                                                                                 |
| `cf`           | Repeatable custom-field filter, `<fieldUlid>:<value[,value…]>`. Values within one `cf` entry OR-compose; repeat `cf` for a different field to AND-compose across fields. Option-based fields take option ULIDs, `checkbox` takes `true`/`false`, `user_picker` takes a user ULID, and the literal token `empty` matches runs with no stored row for that field. Matching considers stored rows only — a materialized default never matches. |

Example:

```http
GET /api/v1/projects/DEMO/runs?status=failed&env=01JXXXXXXXXXXXXXXXXXXXXXXXXX&page=1&pageSize=50
```

### Run response shape

Every `RunResponse` (returned by `GET /runs`, `GET /runs/{runUlid}`, and as the body of `POST /runs`) carries the following fields in addition to the run's identity, metadata, and `state`/`status` axes:

- `runNumber`, `displayId` — the run's per-project number and its readable form `R-{runNumber}` (for example `R-12`). Resolve a display ID with `GET /api/v1/projects/{projectId}/runs/by-display-id/{displayId}` (see [Display ID lookups](#display-id-lookups)).
- `total`, `executed`, `passRate` — legacy progress counters (kept for back-compat).
- `counts` — exact 5-band breakdown of `test_run_cases.status`: `{ passed, failed, blocked, skipped, untested }`. `Σ counts = total`. Use this to render result bars; do not synthesize from `passRate`.
- `firstResultAt`, `lastResultAt` — epoch ms of the first and last `test_results.executed_at` registered on the run; both `null` until the first result lands. Use `lastResultAt − firstResultAt` for the wall-clock elapsed of the run; for an in-progress run use `now − firstResultAt`.
- `totalDurationMs` — sum of `test_results.duration_ms` for the run (results without a measured duration contribute `0`).
- `author` — discriminated snapshot of the actor that created the run, taken at creation time so the run remains renderable after the user or token is deleted. One of:
  - `{ "kind": "user", "ulid": "<userUlid>", "displayName": "...", "avatarKey": <string|null> }`
  - `{ "kind": "api_token", "ulid": "<apiTokenUlid>", "name": "...", "app": "junit" | "playwright" | "jest" | "cypress" | "vitest" | "cucumber" | null }` — `app` names the Integrations app of an [app token](https://docs.probara.net/en/developers/authentication/#app-tokens), and is `null` for an API token.
  - `null` — legacy rows the migration could not recover; render as an em-dash.
- `configurations` — array of the run's configuration tags, each
  `{ ulid, configurationUlid, groupName, valueName }`. `configurationUlid` is
  `null` once the underlying configuration value has been hard-deleted;
  `groupName`/`valueName` are snapshots taken when the tag was set, so the
  combination stays renderable after a rename or deletion. `[]` for a run with
  no configurations. Present on every `RunResponse` — the runs list, the run
  detail, and each plan's run items — never omitted.
- `source` — where a CI run came from, `{ provider, repository, branch, commit, commitAuthor,
pullRequestNumber, workflow, ciRunId, ciRunAttempt, ciJob, buildUrl }` with every key present
  (`null` when unknown), or `null` for a run without a source. See [Run source](#run-source).
- `githubBuild` (on `GET /runs/{runUlid}` only) — the GitHub Actions build of a run launched from
  Probara, or `null`. See the [GitHub API](https://docs.probara.net/en/developers/api/github/#launch-a-workflow).

Runs created from the integrator API (`Authorization: Bearer <apiTokenSecret>`) automatically carry `author.kind = "api_token"`; runs created from a logged-in session carry `author.kind = "user"`. The same shape appears on `GET /api/v1/runs/{runUlid}`.

### Run source

`POST /api/v1/projects/{projectId}/runs` (and a CI report's run, see [Reports](#reports)) accepts an
optional `source` recording where the run's results came from:

```json
{
  "provider": "github_actions",
  "repository": "acme/shop",
  "branch": "feature/cart",
  "commit": "9f2c1ab4e5d6",
  "commitAuthor": "octocat",
  "pullRequestNumber": 128,
  "workflow": "E2E",
  "ciRunId": "9876543210",
  "ciRunAttempt": 1,
  "ciJob": "test",
  "buildUrl": "https://github.com/acme/shop/actions/runs/9876543210"
}
```

- Every field is optional and trimmed. `provider` is `github_actions` or `other`; `repository` is
  `owner/name`; `branch`, `commitAuthor`, `workflow` and `ciJob` are 1–255 characters without
  control characters; `commit` is 1–64 printable characters without spaces (a hash or any revision
  id), and a 7–40 character hex SHA when `provider` is `github_actions`; `ciRunId` is 1–64 printable
  characters without spaces; `pullRequestNumber` and `ciRunAttempt` are positive integers;
  `buildUrl` is an `http` or `https` URL of at most 2048 characters. Anything else answers
  `422 validation_failed` and creates no run.
- `null`, `{}` or a source whose every field is `null` store no source: the run answers
  `source: null`.
- The source is **write-once**. `PATCH /api/v1/runs/{runUlid}` does not accept it (`422`); the only
  later write is a CI report that reuses a run **without** a source, which gives it the report's
  source. A run that already has one keeps it.
- The web app shows the source under the run's title: repository, branch, short commit, author,
  pull request, workflow, attempt and a link to the build.
- A GitHub Actions source with a `repository` and a `pullRequestNumber` makes the run comment its
  results on that pull request when it is closed or aborted, as the project's
  [pull request comments](https://docs.probara.net/en/guides/integrations/github/#pull-request-comments) setting allows,
  while the project's GitHub App installation can reach the repository.
- A run's public share link never includes its source: the build URL may name a private CI host.

### Run custom field values

`custom_fields` definitions accept `entity: "test_run"` alongside `test_case` and `defect`. Run-scoped values are read and written via a dedicated contract, matching the test-case and defect conventions:

- `GET /api/v1/runs/{runUlid}` embeds a `customFieldValues` array with one entry per visible `test_run` field for the run's project. Missing stored values materialize to the field's `defaultValue` (or `null`). **The runs list (`GET /api/v1/projects/{projectId}/runs`) does not embed this array** — reading it always requires the detail endpoint, so paging through runs never pays a per-row cost.
- `PUT /api/v1/runs/{runUlid}/custom-field-values` atomically replaces the run's value set:

```bash
curl -sS -X PUT \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  -H "content-type: application/json" \
  -d '{
    "values": [
      { "fieldUlid": "01J...BUILD", "value": "4471" },
      { "fieldUlid": "01J...REGRESSION", "value": "01J...OPTION_YES" }
    ]
  }' \
  "https://app.probara.net/api/v1/runs/01J.../custom-field-values"
```

A visible field omitted from the body has its stored row deleted; a stored row for a field that is no longer visible for the run's project is left untouched. Empty / `null` values delete the corresponding row. The endpoint returns `200` with the full embedded array — the same shape `GET /api/v1/runs/{runUlid}` returns.

Requires the result execute permission (held by `owner`/`admin`/`member`); a `viewer` token or session receives `403 forbidden` and no row is touched. Only an **aborted** run rejects the write with `409 conflict`; a closed, non-aborted run still accepts it — the same freeze rule every other run mutation follows.

A successful write that changes at least one value folds into the run's existing `run.updated` audit event (`metadata.customFieldChanges`); it never emits a standalone `custom_field` event. Resubmitting a value set identical to what is already stored returns `200` with no new audit event.

There is no single-field `PATCH .../custom-field-values/{fieldUlid}` route for runs — use `PUT` for every write.

To discover the visible `test_run` field ULIDs for a project, call `GET /api/v1/orgs/{orgUlid}/custom-fields?entity=test_run&projectUlid={projectUlid}`.

**Create-time values (`POST /api/v1/projects/{projectUlid}/runs`)**

The create body accepts an optional `customFieldValues` array in the same `{ fieldUlid, value }` shape as `PUT`:

```bash
curl -sS -X POST \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  -H "content-type: application/json" \
  -d '{
    "name": "Sprint 24",
    "caseUlids": ["01J...CASE"],
    "customFieldValues": [{ "fieldUlid": "01J...BUILD", "value": "4471" }]
  }' \
  "https://app.probara.net/api/v1/projects/DEMO/runs"
```

A required `test_run` field always resolves to a value at create time: pass it explicitly, or omit it entirely and the field's `defaultValue` is persisted for you (a required field can never be defined without a non-empty default, so create can never fail for a missing required value). An optional field omitted from the body stores no row at all — its default is only materialized when read, exactly like a run that never called `PUT`. An invalid value for any submitted field (wrong type, unknown option, etc.) rejects the **entire** create with `422 validation_failed` — no run is created and no value row is written. The values are folded into the run's single `run.created` audit event (`metadata.customFieldChanges`); a run created with no run fields emits the same metadata shape as before this field existed.

Cloning a run (`POST .../runs/{runUlid}/clone`) copies the source run's stored values into the new run, translated against the **target project's** field definitions — a stored value for a field no longer visible in the target project is silently dropped, never rejected. The copy folds into the clone's `run.cloned` event; the source run is never modified and never re-audited.

## Reports

`POST /api/v1/projects/{projectId}/reports` records a batch of automated results in one call — the
endpoint CI tooling (a JUnit importer, a framework reporter) uses instead of orchestrating run
creation, case matching and marking over several endpoints. `{projectId}` is the project code.

```json
{
  "run": { "name": "CI #482" },
  "results": [
    {
      "caseDisplayId": "PRB-12",
      "automationKey": "e2e/login.spec.ts > logs in",
      "status": "passed",
      "durationMs": 1840
    },
    {
      "automationKey": "e2e/login.spec.ts > rejects bad password",
      "title": "rejects bad password",
      "suitePath": ["e2e", "login.spec.ts"],
      "status": "failed",
      "notes": "expected 401, got 500"
    }
  ],
  "options": { "createMissingCases": true, "close": false }
}
```

- **`run`** is either `{ "ulid": "…" }` to append to an **open** run of the project, or the fields of
  a new run, with the same rules as `POST /api/v1/projects/{projectId}/runs` — the name forms
  included, see [Run references by name](#run-references-by-name):
  `{ name, description?, environmentId? or environment?, milestoneId? or milestone?, plan?, configurationUlids? or configurations?, tags?, source? }`.
  A `plan` (by name only — no `planUlid`) seeds the new run with the plan's selected cases; one
  whose selection is empty answers `422` naming `plan`, with nothing written. Reusing a closed or aborted run answers
  `409 conflict`; a run of another project answers `404`.
- **`run.source`** (optional on both shapes) is the run's CI source, see [Run source](#run-source). A created run stores it. A reused run takes it only while it has
  none, so every chunk may send the same source: the first one wins and none is ever overwritten.
- **`results`** carries 1–500 entries. Each needs a `caseDisplayId` or an `automationKey` (or both)
  and a `status` (`passed` \| `failed` \| `skipped` \| `blocked`); `durationMs`, `notes`, an ISO
  `executedAt`, `parameters`, `steps`, `links` and `case` are optional (see
  [What an entry can carry](#what-an-entry-can-carry)). Send larger suites in chunks of 500, reusing
  the run the first call returned. Unknown fields, or an entry with neither `caseDisplayId` nor
  `automationKey`, answer `422 validation_failed` with nothing written.
- **Matching**, per entry: a `caseDisplayId` decides when present — it matches that case or the entry
  is unmatched, never falling back to the key. When the matched case has no automation key and no
  other case holds the entry's `automationKey`, the case adopts it: this is how an existing manual
  case gets linked the first time. Otherwise the entry is matched by `automationKey`. A case created
  earlier in the same batch, or one that just adopted the key, is reused, so entries sharing a key
  create one case.
- **Creating cases**: an unknown key that comes with a `title` creates the case under `suitePath`
  (trimmed suite names, at most 10 levels) beneath `options.suiteUlid` or the project root,
  reusing suites of the same name. `options.createMissingCases: false` turns that off. An unknown
  `options.suiteUlid` answers `404` with nothing written.
- **Existing cases**: a matched archived case is restored and records the result. To stop reporting
  a test, skip or remove it in your code. A case matched by `automationKey` whose entry has a
  `suitePath` is moved there, beneath `options.suiteUlid` or the project root, when it sits
  elsewhere, so the test's place in your code decides its suite. A case matched by
  `caseDisplayId`, or managed by a Git sync, keeps its suite, and so does every case when
  `options.createMissingCases` is `false`. A report never changes the title,
  steps or fields of an existing case.
- **Recording**: every matched entry appends one result with its own status, duration, notes,
  `executedAt`, parameters, steps and links; matched cases missing from the run are added to it, and each
  run case keeps the last
  outcome it received. The run is never completed automatically by a report, so the next chunk can
  still reach it; pass `options.close: true` on the last call to close it.
- **Atomic and idempotent**: all writes (run, suites, cases, run cases, results, audit events) commit
  together or not at all. Send an `Idempotency-Key` so a retried batch is replayed instead of
  recorded twice. Each recorded result counts toward the
  [monthly API result limit](#organization-api-result-usage); a batch the limit cannot cover is
  refused whole with `409 api_result_limit_exceeded`. Reusing an `Idempotency-Key` with a different
  body answers `422 validation_failed`. Replays are stored for responses up to 64 KB, which the
  500-entry limit and the bounded `warnings` keep every report under.
- **Sharded CI**: parallel jobs may report into the same run at the same time. Each report commits
  against the run as it is at that moment, so no case is added to the run twice, positions never
  collide, and two shards that create the same new key share one case (the second is not flagged
  `created`). A report whose run was closed meanwhile answers `409 conflict` with nothing written.
  Once a report has committed it always answers `201`: if closing the run afterwards fails, the
  response shows the run still `open` — close it with `POST /api/v1/runs/{runUlid}/close`.
- Requires the run execution and test case write permissions (`owner`/`admin`/`member`); a
  `viewer` receives `403 forbidden`.
- **GitHub Actions jobs grouped into one run**: a report without a run `ulid` whose `run.source`
  has `provider: "github_actions"`, a `repository` and a `ciRunId` appends to the project's run of
  that repository, CI run id and attempt (`ciRunAttempt`, 1 when omitted), and creates that run only
  when there is none. Every job of one workflow run can send the same `run` and they all land in
  **one** run. That run stays open until a final job closes it with `options.close: true` (results
  may then be empty) or `POST /api/v1/runs/{runUlid}/close`. A job reporting after the run was
  closed or aborted answers `409 ci_run_closed` with `details.runUlid`, and nothing is written. A
  re-run of the workflow (another attempt) starts its own run. A run launched from Probara is named
  by its `ulid` in every attempt, since GitHub re-runs a workflow with the same inputs: a report with
  that `ulid` whose `run.source` names another attempt of the run's workflow run appends to that
  attempt's own run, created by its first report and named after the launched run (such as
  `Nightly E2E · attempt 2`). Read the run from the response's `run.ulid`. Probara closes that run
  when the attempt finishes in GitHub. The final job's request:

  ```json
  {
    "run": {
      "name": "E2E #482",
      "source": {
        "provider": "github_actions",
        "repository": "acme/shop",
        "ciRunId": "9876543210",
        "ciRunAttempt": 1
      }
    },
    "results": [],
    "options": { "close": true }
  }
  ```

- **Paid plans only**: reporting from CI is included in the paid plans. On an organization without
  one, a report answers `403 forbidden` with nothing written, for every credential.
- An [app token](https://docs.probara.net/en/developers/authentication/#app-tokens) of the **JUnit XML** app (the
  credential of the Probara CLI), of the **Playwright** app (the credential of the Probara
  Playwright reporter), of the **Jest** app (the credential of the Probara Jest reporter), of the
  **Cypress** app (the credential of the Probara Cypress reporter), of the **Vitest** app (the
  credential of the Probara Vitest reporter) or of the **Cucumber** app (the credential of the
  Probara Cucumber reporter and of the Probara CLI's Gherkin sync) may send reports; the run it creates for them must be
  automated.

### What an entry can carry

Besides its status, an entry can send what a framework reporter knows about the execution and about
the case it creates:

```json
{
  "automationKey": "e2e/checkout.spec.ts > pays with a card",
  "title": "Pays with a card",
  "status": "failed",
  "parameters": { "browser": "chromium", "user": "admin" },
  "steps": [
    {
      "action": "Open the cart",
      "status": "passed",
      "durationMs": 420
    },
    {
      "action": "Pay",
      "status": "failed",
      "durationMs": 1310,
      "error": "Timed out waiting for the receipt",
      "steps": [{ "action": "Fill the card form", "status": "passed" }]
    }
  ],
  "links": [
    { "url": "https://jira.example.com/browse/PAY-12", "name": "PAY-12" },
    { "url": "https://ci.example.com/builds/1834" }
  ],
  "case": {
    "description": "A signed-in user pays with a saved card.",
    "tags": ["smoke", "payments"],
    "fields": { "priority": "High", "severity": "Critical", "Component": "Checkout" },
    "steps": [{ "action": "Pay with the saved card", "expected": "The receipt is shown" }]
  }
}
```

| Field               | What it records                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `contextParameters` | The names in `parameters` that are reporter context, such as a Playwright or Vitest `project`, a Cypress `browser`, or a retry `attempt` and `repeat`. Each name must be one the result reports and is listed once; otherwise the entry answers `422`. Context is shown on the result and never becomes a parameter of a case the report creates.                                                                                                                                                                                                                      |
| `parameters`        | The parameters the execution ran with, name to value, stored with the result in the order sent. At most 20; names and values are trimmed, a name has 1–100 characters and a value at most 500. Two names equal once trimmed, and the name `__proto__`, answer `422`.                                                                                                                                                                                                                                                                                                   |
| `steps`             | The steps the execution ran, as a tree: each step is `{ action, status, keyword?, durationMs?, error?, expected?, data?, steps? }`, `status` as the entry's, and `keyword` is the Gherkin keyword the step was written with (`Given`, `When`, `Then`, `And`, `But` or `*`; Cucumber sends one for each step). At most 200 steps per entry across all levels, nested at most 10 levels deep; `action` 1–2000 characters, `expected` and `data` at most 2000, `error` at most 4000. A step's index is its position in a depth-first, pre-order walk of the tree, from 0. |
| `links`             | Links about the execution — an issue, a TMS page, a build log — as `{ url, name? }`, stored with the result in the order sent. At most 20 per entry; `url` is an absolute `http:` or `https:` URL of at most 2048 characters and `name` 1–255 characters, both trimmed. Any other scheme (`javascript:`, `data:`, `file:`, `ftp:`) or a relative URL answers `422` with nothing written.                                                                                                                                                                               |
| `case`              | What a case the report **creates** starts with: `description`, `tags` (at most 50 names of 1–80 characters; unknown ones join the organization's tag catalog), `fields` and `steps` (at most 500 `{ action, expected?, data? }`, numbered in order).                                                                                                                                                                                                                                                                                                                   |

- **`case` applies only on creation**: only the entry that creates the case — the first entry of a
  new automation key, with its `title` — applies it. Entries that match an existing case, and later
  entries of the same key, ignore it without a warning: a report never modifies an existing case.
- **`fields`** names at most 50 fields, matched ignoring case: a system field key (`priority`,
  `severity`, `type`, `layer`, `behavior`, `status`, `preconditions`, `postconditions`, `is_flaky`)
  or the title of a test case custom field visible to the project. A select or radio value names an
  option (ignoring case; `;` separates the options of a multi-select); a checkbox is false for
  `false`, `0`, `no` or `n` and true otherwise; other fields take the value as text, checked against
  the field type. A field or value that does not resolve — an unknown name, an unknown option, a
  value the field type refuses — is **skipped and listed in `warnings`**; it never fails the report.
  A field or option deleted while the report runs is skipped the same way
  (`Field "Platforms" changed during the report and was skipped`); for a multi-select, the whole
  field is skipped when any of its options was deleted.
- **An empty value is skipped silently**: a value that is empty once trimmed sets nothing, a
  checkbox included (it is never stored as false), and adds no warning, whatever the name.
- **Totals per report**: across all its entries, one report carries at most 10,000 result steps
  (`steps`, every level counted; `REPORT_RESULT_STEPS_MAX_TOTAL`), 10,000 case steps (`case.steps`;
  `REPORT_CASE_STEPS_MAX_TOTAL`) and 1,000 case tag names (`case.tags`;
  `REPORT_CASE_TAGS_MAX_TOTAL`). Case steps and tags count whether or not the entry creates its
  case. Links need no total of their own: 500 entries of at most 20 links already bound a report to 10,000. A larger report
  answers `422 validation_failed` with nothing written, and the issue names
  the total and the report's count — for example `a report carries at most 1000 case tag names
across all entries (this one has 1001); send the entries in smaller chunks`. Split it into more
  chunks.
- **Step attachments**: attach a file to a step by committing it with the step's index as
  `stepIndex` (see [Result attachments](https://docs.probara.net/en/developers/api/result-attachments/)).
- Read the parameters, steps and links back as `parameters`, `resultSteps` and `links` on the
  result's [attempt snapshot](https://docs.probara.net/en/developers/api/run-result-snapshots/), and for the case's current
  attempt on the [run-case detail](https://docs.probara.net/en/developers/api/run-cases/#get-a-case-of-a-run). The report
  response never echoes them. The web app shows each link as an external link, labelled by its
  `name` or else its URL.

### Parameters of a report

A result's `parameters` select the run case of the test case it reports, through the names the case declares:

- A name the case declares selects the run case with exactly the values the result reports for the declared names. A name the case does not declare stays on the result for display, and never splits a run case.
- If the run holds no run case with those values, Probara adds one, even when the case does not list those values. A result that reports none of the declared names lands on the run case with no values.
- A case the report creates takes an `Examples` parameter from the data its results report: a single when they name one parameter, a group when they name several. It is created only when at least two distinct data sets name the same parameters. Context names (`contextParameters`) are left out of that parameter.

See [Parameterized test cases](https://docs.probara.net/en/guides/parameterized-test-cases/).

### Assigning failed results

`options.assignFailedTo` names the people who triage what the report leaves failed, by email:

```json
{ "options": { "assignFailedTo": ["ana@example.com", "luis@example.com"] } }
```

- **Which run cases**: each run case whose result from this report is `failed` — the last entry of
  the case in the report decides — and that has **no assignee**. They are assigned round-robin over
  the members, in the order given, following the order of the entries: three failed cases over two
  members go to the first, the second, then the first again. The round-robin starts again with the
  first member in every report: a suite sent as several reports into one run starts each of them
  with the first member.
- **Never overwritten**: a run case that already has an assignee (set by someone, by the run's
  default assignee or by an earlier report) keeps it and takes no turn. Passed, skipped and blocked
  run cases are never touched.
- **Emails**: 1–20, trimmed and compared ignoring case; a repeated email counts once. An email
  resolves only to an **active member** of the organization who could work on the run case: their
  role executes runs (owner, admin or member; a viewer or a custom role does not), and the project
  is open to them (on a private project, an owner or admin, or someone granted access individually
  or through a group; never someone whose access to the project was revoked). So a case of a
  private project is never assigned, nor its run named in a notification, to someone outside it.
  One that does not resolve is skipped, and the response carries a single warning, always the
  first of `warnings`, that only counts them and never names which failed:
  `assignFailedTo: 1 of 2 emails did not match a member who can be assigned in this project`.
  When none resolves, nothing is assigned. The count does not hide membership from the caller: a
  report that names a single email learns whether it can be assigned. An app token is an
  organization credential that an owner or admin issues, and it can learn only whether an email it
  already knows can be assigned in that project. An email that is not an email answers `422` with
  nothing written.
- **Never fails the report**: the assignment commits in the same transaction as the results, and an
  `Idempotency-Key` replay answers the stored response without assigning again. Each member's
  assignment is audited like a bulk assignment (`run.cases_bulk_assigned`), attributed to the
  report's user or app token, and the member is notified once for the run, as when assigned by
  hand — never the user who sent the report.

### Response

The `201` response answers each entry in request order — `results[i]` is the outcome of entry `i`:

```json
{
  "run": { "ulid": "01K…", "displayId": "R-7", "state": "open" },
  "results": [
    { "outcome": "recorded", "caseUlid": "01K…", "resultUlid": "01K…" },
    { "outcome": "recorded", "caseUlid": "01K…", "resultUlid": "01K…", "created": true }
  ],
  "summary": { "recorded": 2, "created": 1, "unmatched": 0 },
  "warnings": ["Unknown field \"Sevrity\" was skipped"]
}
```

`warnings` is present only when the report skipped something without failing, such as a case field
it could not resolve or one deleted while the report ran, or `assignFailedTo` emails that could not
be assigned (that warning always comes first). It is deduplicated and holds at most 20 items of at
most 300 characters (4 KB in all); when more were left out, the last item counts them.

An unmatched entry records nothing and carries a `reason`: `case_not_found` (no case holds the
display id or key, and none was created), `title_required` (creation was allowed but the entry had no
`title`) or `invalid_display_id` (malformed, or the display id of another project's case). Use each
`resultUlid` to attach files through the result attachment endpoints.

The same report is available to AI assistants as the MCP tool `submit_report` (see the
[MCP tool reference](https://docs.probara.net/en/developers/mcp-tools/)): its input is this request body plus `project_id`
and an optional `idempotency_key`, and it answers this response.

## Gherkin sync

`POST /api/v1/projects/{projectId}/gherkin-sync` makes the synced cases of a project match the scenarios of `.feature` files that a CI job parsed. `{projectId}` is the project code. The Probara CLI (`probara sync gherkin`) and the [`sync_gherkin_features` MCP tool](https://docs.probara.net/en/developers/mcp-tools/#sync_gherkin_features) send this request. The [sync guide](https://docs.probara.net/en/guides/syncing-feature-files-from-git/) explains the rules for customers.

The body carries the `scope` of the sync and the features with their scenarios. `suiteUlid` names the suite the feature suites go under (the project root when omitted), and `dryRun: true` answers the same outcome and writes nothing:

```json
{
  "scope": ["features/**/*.feature"],
  "features": [
    {
      "path": "features/checkout/pay.feature",
      "name": "Checkout",
      "tags": ["payments"],
      "scenarios": [
        {
          "automationKey": "features/checkout/pay.feature > Checkout > Pay with a card",
          "name": "Pay with a card",
          "line": 12,
          "tags": ["smoke"],
          "steps": [
            { "keyword": "Given", "text": "a cart with one item" },
            { "keyword": "Then", "text": "the order is paid" }
          ],
          "url": "https://git.example.com/acme/shop/blob/main/features/checkout/pay.feature#L12"
        }
      ]
    }
  ],
  "dryRun": false
}
```

- `scope` is 1 to 20 globs, such as `features/**/*.feature`. Every feature's `path` must match one of them, and only synced cases whose file matches `scope` can be archived.
- A sync carries at most 1,000 scenarios. Each scenario has its `automationKey` (`<path> > <Feature> > <Scenario>`), its `line`, its `steps` with their `keyword`, and optionally its `background`, its `examples` (for a Scenario Outline), its `rule` and its `url` (an `https` link).

Each scenario is matched in this order. A `probara:<case display ID>` tag binds it to that case, whatever its key. Otherwise its `automationKey` finds the case that holds it. Otherwise a new key whose content hash (its steps, Background and Examples) equals that of exactly one synced case that disappeared in the same sync moves that case. Otherwise the scenario creates a case. A synced case whose scenario is gone is archived, and a scenario that matches an archived case restores it. A manual case that a key or a binding finds becomes a synced case. The [sync guide](https://docs.probara.net/en/guides/syncing-feature-files-from-git/#moves-renames-and-bindings) has the examples.

The `200` answer has the outcome:

```json
{
  "dryRun": false,
  "summary": {
    "created": 1,
    "updated": 0,
    "moved": 0,
    "restored": 0,
    "archived": 0,
    "unchanged": 0
  },
  "cases": [
    {
      "action": "created",
      "automationKey": "features/checkout/pay.feature > Checkout > Pay with a card",
      "previousAutomationKey": null,
      "caseUlid": "01J...CASE",
      "caseDisplayId": "SHOP-14",
      "path": "features/checkout/pay.feature",
      "line": 12
    }
  ]
}
```

`cases` has one entry per scenario, in request order, then one per archived case. `action` is `created`, `updated`, `moved`, `restored`, `archived` or `unchanged`. `previousAutomationKey` is set for a move. `caseUlid` and `caseDisplayId` are `null` for a case that a dry run would create, and `line` is `null` for an archived case.

Refusals write nothing:

- `403 forbidden`: the plan does not include CI reporting, which the sync shares, or the role cannot write test cases or run executions (a `viewer`).
- `404 not_found`: the project, or the `suiteUlid`, does not exist.
- `422 validation_failed`: the body breaks the schema (an unknown field, a `path` that is not a repository-relative `.feature` path, more than 1,000 scenarios or 20 globs); a feature `path` matches none of `scope`; two scenarios share an `automationKey` (both lines are named); or a `probara:` tag names an unknown case or a case of another project, a scenario carries two such tags, or two scenarios bind one case.

A sync writes one `test_case.synced` audit event for the project, with its counts and scope, and one `test_case.archived` or `test_case.restored` event for each case it archives or restores. The cases it creates or updates have no event of their own, so they send no `test_case.created` or `test_case.updated` [webhook](https://docs.probara.net/en/developers/webhooks/).

## Organization members

`GET /api/v1/orgs/{orgUlid}/members` lists the organization's members with page-based pagination. Add `?status=active` to list the members who can sign in, `?status=deactivated` to list the ones an admin deactivated, or leave `status` out to list everyone. Every row carries `deactivatedAt`: the epoch-millisecond instant an admin deactivated the member, or `null` while the member is active.

Admins deactivate a member instead of removing them. An owner or admin (`org-members.manage`) calls:

- `POST /api/v1/orgs/{orgUlid}/members/{userUlid}/deactivate` ends the member's access to the organization at once. Their personal API tokens, OAuth and MCP grants, single sign-on sessions and break-glass admissions for the organization are revoked, and their seat is freed. The membership, role, project access, groups and authorship stay.
- `POST /api/v1/orgs/{orgUlid}/members/{userUlid}/reactivate` gives the access back and takes a seat. It needs a free seat: without one the request answers `409 seat_limit_exceeded` and the member stays deactivated. The revoked tokens and grants are not restored, so the member needs new ones.

Both calls send a JSON body (`{}` is enough), answer `200` with `{ "member": … }`, and record a `member.deactivated` or `member.reactivated` audit event. An owner cannot be deactivated and you cannot deactivate yourself (`409 conflict`), and a member who is already in the requested state gets the same answer.

`DELETE /api/v1/orgs/{orgUlid}/members/{userUlid}` no longer exists. A person who leaves the organization still removes their own membership with `POST /api/v1/orgs/{orgUlid}/memberships/leave`, and an erased account is removed from every organization it belonged to.

When a downgrade to Free leaves more members than its three seats, Probara deactivates the excess automatically: the newest members first, never the owner. Those audit events carry `metadata.reason` `seat_overflow`, and a deactivated member can be reactivated once a seat is free. Viewers count against the Free seats; on a plan with free viewers they do not.

## Organization API result usage

`GET /api/v1/orgs/{orgUlid}/api-result-usage` reports the organization's authoritative counted results for the **current** calendar month plus its effective `apiResultsPerMonth` ceiling — the same figures the monthly API result limit above enforces, never a recomputed count. Any authenticated member of the organization may call it; there is no role gate.

```json
{ "periodKey": "2026-08", "used": 42, "limit": 5000 }
```

`periodKey` is an opaque `YYYY-MM` (UTC) tag — treat it as an identifier, not something to parse or compute against. Reading usage never creates, resets, or advances the counter, so polling it has no side effect on your ceiling. Once the calendar month turns, the readout reports `0` for the new period automatically, with no operator action or deploy — mirroring the same rollover the write-side limit already performs lazily on the next counted result.

## Organization seat usage

`GET /api/v1/orgs/{orgUlid}/seat-usage` reports the organization's used seats and its effective seat limit — the same used-seat definition the seat-limit enforcement above compares, never a recomputed or roster-derived count. Any authenticated member of the organization may call it; there is no role gate.

```json
{ "used": 2, "limit": 3 }
```

A staff-suspended member still occupies a seat here even though the visible member roster hides that member — the readout always agrees with the guard, never with the roster. A pending, unexpired invitation reserves a seat the same way it does for enforcement; an expired-but-still-pending invitation reserves none. Reading seats never mutates anything: it does not expire a stale invitation, revoke anything, or refuse on seat grounds by itself. An unlimited effective limit is returned exactly as the entitlements reader resolved it. A deactivated member holds no seat (see [Organization members](#organization-members)).

## Organization audit log export

`GET /api/v1/orgs/{orgUlid}/audit-events/export` downloads the organization's audit log as a single CSV artifact — one row per audit event, ordered chronologically — for a compliance or security reviewer who needs "the last quarter of workspace activity, as a file." It requires organization membership with role `owner` or `admin`, **and** the `auditExportEnabled` entitlement (an Enterprise capability). Both refusals return `403 { code: "forbidden" }` and are indistinguishable on the wire — a caller cannot learn whether the organization holds the entitlement by testing their own role, or vice versa.

Both `from` and `to` query parameters are **required**, ISO-8601 UTC with the `Z` designator (a bare date or an offset-bearing literal is rejected). The window is half-open — `from` is inclusive, `to` is exclusive — so two consecutive calls with `to` of the first equal to `from` of the second never produce a duplicate or a gap at the boundary instant. The span may not exceed 366 days; a missing parameter, an inverted window (`to <= from`), or an oversized span all return `422 { code: "validation_failed" }`.

An exact count of matching events is taken **before** any row is read. A window whose count exceeds 25,000 events is refused `422 { code: "validation_failed" }` with a message naming both the actual count and the ceiling — narrow the window and retry. The export is rate-limited to 10 calls per organization per hour, on its own bucket independent of the test-case export limit above; exceeding it returns `429 { code: "too_many_requests" }`.

The response is `Content-Type: text/csv; charset=utf-8` with `Content-Disposition: attachment` naming a filename that carries the requested window, so two exports never collide in a downloads folder. Columns: `created_at, event_ulid, action, actor_kind, actor_ulid, actor_label, entity_type, entity_ulid, entity_label, run_ulid, metadata`. A cell whose source value could be interpreted as a spreadsheet formula (starting `= + - @`) is neutralized with a leading `'` before quoting. The `run_ulid` column is empty for an event that was never associated with a test run, carries the run's ULID when the run still exists, and carries the literal `[run purged]` or `[run unknown]` sentinel for the two cases where the run no longer exists but the event was (or may have been) run-scoped. An actor whose identity has since been erased renders `[identity removed]` in `actor_label` rather than any resurrected personal detail. Exporting is a pure read: it never mutates or deletes audit data, and calling it does not itself generate a new audit event.

## Changelog

- **Breaking — rich text fields and inline images.** Long text fields take Markdown in `<field>` or a document in `<field>Document`, one per write, and responses carry both (see [Rich text fields](#rich-text-fields)). Images are inline: stage them with `POST /api/v1/images:stage` and reference them in the text. The step, comment, shared step item and run step image galleries are removed, with their endpoints and MCP tools. Naming an `attachments` list on a step or a comment is refused. Result and defect file attachments are unchanged.

- **Gherkin Git sync and Cucumber results.** New `POST /api/v1/projects/{projectId}/gherkin-sync` makes the synced cases of a project match `.feature` files (see [Gherkin sync](#gherkin-sync)). Test cases carry `source` (`manual` or `git`) and, for a `git` case, `sourceFile`. A save that changes a synced case answers `409 test_case_synced_read_only`. Reported result steps accept `keyword`, and the version history's `source` includes `git_sync`.

- **Breaking — `DELETE /api/v1/orgs/{orgUlid}/members/{userUlid}` is removed.** Deactivate the member with `POST /api/v1/orgs/{orgUlid}/members/{userUlid}/deactivate` instead, and reactivate them with `.../reactivate`. `GET /api/v1/orgs/{orgUlid}/members` accepts `?status=active|deactivated` and every member carries `deactivatedAt`. See [Organization members](#organization-members).

- **Plans are Free, Pro and Enterprise.** Plan keys in subscription and organization payloads are `free`, `pro` and `enterprise`. `POST /api/v1/orgs/{orgUlid}/subscription/trial` accepts only `tierKey` `pro`. SCIM answers `403` with `SCIM requires the Enterprise plan.` on any other plan, and the audit log export requires the Enterprise plan. See [Billing](https://docs.probara.net/en/guides/billing/).

- **Organization two-factor requirement.** `GET /api/v1/orgs/current` carries `requireTwoFactor`.
  While it is `true`, a member signed in to the web app whose account has no two-factor
  authentication is refused on organization routes with `403 organization_two_factor_required`.
  API keys, app tokens and MCP clients are not affected. The owner changes it from
  **Workspace → Settings**; see [Workspace](https://docs.probara.net/en/guides/workspace/).

- **Test case review: bulk actions and Assigned to me (Pro).** New
  `POST /api/v1/projects/{projectId}/test-case-reviews/bulk` opens one review request per case (up to 200) that reviews the case as it is: `currentVersionOnly` is `true`, and merging it marks the case
  `approved` without writing a revision. New `POST .../test-case-reviews/bulk-decision` approves,
  requests changes on or declines up to 50 requests through the single actions, with a `note`
  required to request changes or decline. `POST .../{reviewUlid}/decline` takes an optional `note`,
  shown as `declineNote`. The list takes `assignedToMe` and `needsMyReview`, and the summary adds
  `needsMyReviewCount`. Review requests carry `currentVersionOnly` and `declineNote`. New MCP tools
  `bulk_create_test_case_reviews` and `bulk_decide_test_case_reviews`; `decline_test_case_review`
  takes `note` and `list_test_case_reviews` the two new filters. See
  [Test case review requests](https://docs.probara.net/en/developers/api/test-case-reviews/#send-cases-for-review-as-they-are).

- **Test case review: every write channel and the MCP actions (Pro).** Under mandatory review,
  `POST /api/v1/projects/{projectId}/test-cases` and `PATCH /api/v1/test-cases/{caseUlid}` answer
  `202` with the review request they opened instead of `409 review_required`, and a commit to
  `POST .../imports/commit` opens one review request per case: the import report gains
  `counts.reviewRequestsCreated` and `reviewRequests`, and the failure codes `review_already_open`
  and `review_proposal_invalid`. `GET .../exports?scope=filtered` takes the `reviewStatus` filter.
  New MCP tools `list_test_case_reviews`, `get_test_case_review_versions`,
  `approve_test_case_review`, `unapprove_test_case_review`, `request_changes_test_case_review`, `update_test_case_review`,
  `set_test_case_review_reviewers`, `decline_test_case_review` and `merge_test_case_review`; under
  mandatory review `create_test_case` and `update_test_case` return
  `{ sentToReview: true, message, review }`. See
  [Mandatory test case review](#mandatory-test-case-review-202-and-review_required).

- **Test case review: comments and notifications (Pro).** New
  `/api/v1/projects/{projectId}/test-case-reviews/{reviewUlid}/comments` endpoints (list, create, edit,
  delete, pin, unpin) with the test case comment shapes, recording `test_case_review.commented`,
  `comment_updated`, `comment_deleted`, `comment_pinned` and `comment_unpinned`. Five notification
  kinds in a new **Review** group: `test_case_review.assigned`, `test_case_review.decided` (with the
  decision in `payload.context.status`), `test_case_review.edited`,
  `test_case_review.comment_created` and `test_case_review.comment_mention`, with `entityType`
  `test_case_review`. Submitting a new case whose suite or milestone belongs to another project now
  answers `422 validation_failed`, and two concurrent submissions for one case answer
  `409 review_already_open` to the second. See
  [Test case review requests](https://docs.probara.net/en/developers/api/test-case-reviews/#comments-on-a-request).

- **Test case review: the Review actions (Pro).** New `GET /api/v1/projects/{projectId}/test-case-reviews`
  (list, with `status`, `kind`, `author` and `reviewer` filters), `GET .../test-case-reviews/summary`,
  `GET .../{reviewUlid}/versions` (the proposed version), `POST .../{reviewUlid}/approve`,
  `POST .../{reviewUlid}/unapprove`, `POST .../{reviewUlid}/request-changes`, `PATCH .../{reviewUlid}` (edit the proposal),
  `PUT .../{reviewUlid}/reviewers`, `POST .../{reviewUlid}/decline` and
  `POST .../{reviewUlid}/merge`. A merged update is stored as a case revision with `source`
  `review`. Each action records a `test_case_review.*` audit event (`approved`, `changes_requested`,
  `edited`, `reviewers_updated`, `declined`, `merged`). The request read gains `case`,
  `approvalCount`, `approvalsRequired`, `allowSelfMerge`, `conflict`, and per reviewer `current` and
  `note`. `PATCH .../review-settings` now keeps a default reviewer who is hidden because they lost
  access, and a reordered reviewer list no longer records an audit event. See
  [Test case review requests](https://docs.probara.net/en/developers/api/test-case-reviews/).

- **Test case review requests by display id (Pro).** New
  `GET /api/v1/projects/{projectId}/test-case-reviews/by-display-id/{displayId}` and the MCP tool
  `get_test_case_review_by_display_id` read a request by its `RV-{n}` id, the id review URLs in the
  app now use. See [Read a review request](https://docs.probara.net/en/developers/api/test-case-reviews/#read-a-review-request).

- **Test case review requests (Pro).** New `POST /api/v1/projects/{projectId}/test-case-reviews`
  and `GET /api/v1/projects/{projectId}/test-case-reviews/{reviewUlid}`, and the MCP tools
  `create_test_case_review` and `get_test_case_review`, send a new case or a change to an existing
  case for review and read a request. Submitting records a `test_case_review.created` audit event.
  While a project's review is mandatory, the direct case writes answer `409 conflict` with
  `details.reason` `review_required`; see
  [Mandatory test case review](#mandatory-test-case-review-202-and-review_required). Default reviewers must
  now also hold the `test-cases.review` permission, or `PATCH .../review-settings` answers `422`.
  See [Test case review requests](https://docs.probara.net/en/developers/api/test-case-reviews/).

- **Test case review settings (Pro).** New `GET`/`PATCH /api/v1/projects/{projectId}/review-settings`
  and the MCP tools `get_project_review_settings` and `update_project_review_settings` read and
  change a project's test case review settings. Changes record a `project.review_settings_updated`
  audit event. `GET /api/v1/orgs/current` carries `caseReviewEnabled`: `true` when the plan includes
  test case review (Pro or Enterprise), `false` otherwise. Custom roles gain the `test-cases.review`
  permission. See [Test case review settings](https://docs.probara.net/en/developers/api/project-review-settings/).

- **GitHub integration.** New endpoints link GitHub issues to test cases, runs and defects, create
  issues from defects, and launch GitHub Actions workflows; see the [GitHub API](https://docs.probara.net/en/developers/api/github/).
  Traceability reports accept GitHub repositories as their source. A run's `source` gains
  `provider`, `repository`, `commitAuthor`, `pullRequestNumber`, `workflow`, `ciRunId`,
  `ciRunAttempt` and `ciJob`; GitHub Actions reports group into one run per workflow run attempt,
  and a report to a closed group answers `409 ci_run_closed`. `GET /runs/{runUlid}` gains
  `githubBuild`.
- **Traceability reports replace the Jira coverage endpoints.** `GET /api/v1/projects/{projectId}/integrations/jira/coverage` and `GET /api/v1/projects/{projectId}/integrations/jira/unlinked-issues` are removed, and so are the `get_jira_coverage` and `list_jira_unlinked_issues` MCP tools. A traceability report takes a snapshot of the requirements of the Jira projects and issue types you choose, and each version is generated in the background. See the [traceability reports API](https://docs.probara.net/en/developers/api/traceability-reports/) and the [MCP tool reference](https://docs.probara.net/en/developers/mcp-tools/).

- **Saved queries and PQL.** `POST /api/v1/query`, `GET /api/v1/query/schema`, and the saved query endpoints under `/api/v1/queries` (list, create, read, replace, delete and run) are available on the Pro and Enterprise plans. See [PQL queries](https://docs.probara.net/en/developers/api/pql/) and [Saved queries](https://docs.probara.net/en/developers/api/saved-queries/). Changes to saved queries are recorded in the audit log as `saved_query.*` events.
- **`GET /api/v1/orgs/current` carries `managedPlan`.** The current-organization echo gains `managedPlan`: `"enterprise"` while an Enterprise contract governs the organization, `"managed"` while a comp or internal contract does, and `null` otherwise. The field is the label only; the contract's price and terms are never exposed. Self-serve billing answers `409 { code: "conflict", details: { reason: "contract_active" } }` while either label is set.

- **Records behind a widget value.** `GET /api/v1/dashboards/{dashboardUlid}/widgets/{widgetUlid}/records`
  and `list_dashboard_widget_records` list, page by page, the test cases, defects, runs, results or
  suites behind one value of a widget: a count, a created count, a distribution bucket or a time
  series bucket. The `total` is the value the widget shows for that selector. See
  [Dashboard widgets](https://docs.probara.net/en/developers/api/dashboard-widgets/#widget-records).

- **Multi-select `other` buckets count each case once.** In a `distribution` widget over a
  `select_multi` field, the `other` bucket counts the test cases that hold a folded option, each
  once. Before, a case that held two folded options was counted twice, so `other` could be higher
  than the number of cases in it. See [Dashboard widgets](https://docs.probara.net/en/developers/api/dashboard-widgets/#distribution).

- **Custom field values on test case create.** `POST /api/v1/projects/{projectId}/test-cases` and
  `create_test_case` accept an optional `customFieldValues` array, so a case is created with its
  preconditions, postconditions or other fields in one call and one revision. See
  [Test case custom field values](#test-case-custom-field-values).

- **Dashboard widget filters.** Data widgets take `filters` on `POST` and `PATCH` of a dashboard
  widget and on `create_dashboard_widget` and `update_dashboard_widget`, and every widget response
  lists them with a display label per value. The fields a widget offers depend on what its metric
  counts; a filter value that names nothing you can use returns `422 validation_failed`. See
  [Dashboard widgets](https://docs.probara.net/en/developers/api/dashboard-widgets/#filters).

- **Deselecting the visible projects of a widget.** On `PATCH` of a dashboard widget and
  `update_dashboard_widget`, a `selected` scope's `projectUlids` may now be empty, so an editor can
  remove every project they can open while projects hidden from them remain. A `selected` scope
  that would be left with no project at all returns `422 validation_failed`. See
  [Dashboard widgets](https://docs.probara.net/en/developers/api/dashboard-widgets/#project-scope).

- **Distribution and time series widget data.** The widget data endpoint and
  `get_dashboard_widget_data` now answer `distribution` widgets (test cases per value of a custom
  field, with a `not_set` bucket, `multiValue` for multi-select fields, and `fieldRemoved` once the
  field is deleted) and `time_series` widgets (day, week or month buckets on the midnights of the
  caller's profile time zone, with one value per bucket for each series of the metric) and `pass_rate` widgets (one pooled value of the runs closed in the range, with its counts), instead of
  `422 unsupported widget type`. See
  [Dashboard widgets](https://docs.probara.net/en/developers/api/dashboard-widgets/#distribution).

- **Dashboard widget data.** `GET /api/v1/dashboards/{dashboardUlid}/widgets/{widgetUlid}/data`
  and the MCP tool `get_dashboard_widget_data` return what a `count` or `created_count` widget
  shows, computed with the caller's own project access. `scope` says how many projects were
  counted and how many were hidden, and a timed widget's `range` says when the plan's history
  retention cut it. See
  [Dashboard widgets](https://docs.probara.net/en/developers/api/dashboard-widgets/#widget-data).

- **Dashboard widgets.** New endpoints under `/api/v1/dashboards/{dashboardUlid}/widgets` list,
  add, change, reorder, and remove the widgets of a dashboard, from a fixed catalog (section title,
  count, created count, distribution, time series), with matching MCP tools such as
  `create_dashboard_widget`. A widget's selected projects are always shown through the caller's
  own project access, with a count of the hidden ones. See
  [Dashboard widgets](https://docs.probara.net/en/developers/api/dashboard-widgets/).

- **Public dashboards hold no group grants.** `POST /api/v1/dashboards/{dashboardUlid}/access/groups`
  and the `add_dashboard_access_group` MCP tool refuse a public dashboard with `409 conflict`, as a
  `granted` member grant already was. Making a dashboard public with
  `PATCH /api/v1/dashboards/{dashboardUlid}/access` now also removes its group grants, recording a
  `group_revoked` event for each. See
  [Dashboards](https://docs.probara.net/en/developers/api/dashboards/#make-a-dashboard-public-or-private).

- **Dashboard access in one call.** `POST /api/v1/dashboards` and `create_dashboard` accept
  `members` and `groups` for a private dashboard, granted in the same step as the dashboard is
  created. New `PUT /api/v1/dashboards/{dashboardUlid}` and the `replace_dashboard_settings` MCP
  tool set a dashboard's name, description, visibility, granted members and groups together, all
  or nothing; `revoked` grants are left as they are. See
  [Dashboards](https://docs.probara.net/en/developers/api/dashboards/#replace-settings).

- **Private dashboards at creation.** `POST /api/v1/dashboards` and the `create_dashboard` MCP tool
  accept an optional `accessMode` (`public` by default, or `private`), so a dashboard meant to be
  private is never visible to the rest of the organization. The `dashboard.created` audit event
  records the visibility it was created with. See
  [Dashboards](https://docs.probara.net/en/developers/api/dashboards/#create).

- **Dashboard access.** New endpoints under `/api/v1/dashboards/{dashboardUlid}/access` make a
  dashboard public or private and manage its member grants (`granted` or `revoked`) and group grants,
  with matching MCP tools such as `update_dashboard_access` and `set_dashboard_access_member`. Only
  the dashboard's creator and organization owners and admins can read or change them. Each change
  records a `dashboard.access_changed` audit event. See
  [Dashboards](https://docs.probara.net/en/developers/api/dashboards/#access).

- **Dashboards.** New `/api/v1/dashboards` endpoints create, list, read, rename and delete
  organization dashboards (Pro and Enterprise plans), with the MCP tools `list_dashboards`,
  `get_dashboard`, `create_dashboard`, `update_dashboard` and `delete_dashboard`. A dashboard you
  cannot see answers `404`; one you can see but did not create answers `403` on changes, unless
  you are an organization owner or admin. See [Dashboards](https://docs.probara.net/en/developers/api/dashboards/).

- **Dashboards plan flag.** `GET /api/v1/orgs/current` carries `dashboardsEnabled`: `true` when the
  organization's plan includes dashboards (Pro and Enterprise), `false` otherwise.

- **Images only earlier revisions hold.** `GET /api/v1/orgs/{orgUlid}/attachments` lists them with
  `surface` `test_case_revision` and a new `testCase` field (`null` on other surfaces);
  `attachments:bulk-delete` deletes them, and deleting a `step_attachment` there removes it from
  every revision too, freeing its bytes at once. Revision snapshots mark such images `deleted`, and a
  restore reports them in `skipped` with `kind` `image`. See
  [Version history](#version-history-revisions). Both enums gained a value (`surface`
  `test_case_revision`, `skipped[].kind` `image`): a client that switches on them must accept values
  it does not know, since `/api/v1` adds enum values without a new version.

- **Version history needs Pro or Enterprise.** On other plans,
  `GET /api/v1/test-cases/{caseUlid}/revisions`, `GET .../revisions/{revision}` and
  `POST .../revisions/{revision}/restore` (and the MCP tools `list_test_case_revisions`,
  `get_test_case_revision` and `restore_test_case_revision`) answer `403 forbidden`. Saving still
  stores a revision on every plan. `GET /api/v1/orgs/current` carries `caseHistoryEnabled`. See
  [Version history](#version-history-revisions).

- **Restore a test case revision.** `POST /api/v1/test-cases/{caseUlid}/revisions/{revision}/restore`
  brings a case back to an earlier revision as a new revision (`source` `restore`) and lists what
  could not come back in `restore.skipped`. MCP tool `restore_test_case_revision`. See
  [Restoring a revision](#restoring-a-revision).

- **Test case version history.** `GET /api/v1/test-cases/{caseUlid}/revisions` lists a case's
  stored revisions, newest first (cursor-paged), and `GET .../revisions/{revision}` returns one with
  its `snapshot`. MCP tools `list_test_case_revisions` and `get_test_case_revision`. See
  [Version history](#version-history-revisions).

- **Custom field configuration needs a paid plan.** On Free, `POST /api/v1/orgs/{orgUlid}/custom-fields`
  and `PATCH .../custom-fields/{fieldUlid}` (system fields included) answer `403 forbidden`.
  Listing fields, `DELETE .../custom-fields/{fieldUlid}`, `POST .../custom-fields/{fieldUlid}/reset`
  and every custom-field value write work on every plan. `GET .../custom-fields` carries
  `customFieldsEnabled`, `true` when the organization may create and edit fields.

- **The token that executed a result.** Every result (`GET /api/v1/runs/{runUlid}/results`, the
  `result` of `GET /api/v1/runs/{runUlid}/results/{resultUlid}` and the public share) carries
  `executedByApiToken`: `{ ulid, name, app }` for a result an API token wrote — `app` is the CI
  reporter of an app token, `null` for a personal API token — and `null` for a result recorded in
  the app or once the token is deleted. A result an app token reported used to name no executor at
  all. See [Who executed it](https://docs.probara.net/en/developers/api/run-result-snapshots/#who-executed-it).

- **Run case keys for CI reporters.** `GET /api/v1/runs/{runUlid}/case-keys` lists the cases now
  in a run as `{ caseDisplayId, automationKey }` (`automationKey` is `null` when the case has none),
  cursor-paged (`limit` default 50, max 200). It is the first read an app token may make, so a
  framework reporter can run only the tests linked to an existing run's cases; another
  organization's run answers `404 not_found`. See
  [List a run's case keys](https://docs.probara.net/en/developers/api/run-cases/#list-a-runs-case-keys).

- **Assign failed results.** A CI report accepts `options.assignFailedTo` (1–20 member emails): each
  run case the report leaves `failed` and unassigned is assigned round-robin to the members,
  starting again with the first member in every report; an existing assignee is never overwritten,
  and an email that is not an active member whose role can execute runs and who can access the
  project is only counted in `warnings`. See
  [Assigning failed results](#assigning-failed-results).

- **Links on reported results.** A CI report entry accepts `links` (`[{ url, name? }]`, at most 20,
  absolute `http:`/`https:` URLs only; any other scheme answers `422`). They are stored with the
  result and returned as `links` on the attempt snapshot and, for the current attempt, on the
  run-case detail. See [What an entry can carry](#what-an-entry-can-carry).

- **Framework reporters: steps, parameters, case fields and references by name.** A CI report
  entry accepts `parameters`, a `steps` tree and `case` (description, tags, fields and steps
  applied only when the entry creates the case); the response may carry `warnings`, and one report
  holds at most 10,000 result steps, 10,000 case steps and 1,000 case tag names.
  `POST /api/v1/projects/{projectId}/runs` takes `environment`, `milestone`, `plan` and
  `configurations` by name, and the report's new run takes them the same way (the plan only as
  `plan`); giving both forms of one reference answers `422` (previously `environmentId` won over
  `environment`), a plan given as `plan` with an empty selection answers `422` naming `plan`, and a
  refused run create no longer creates the environment it names. A result attachment commit takes
  `stepIndex`; the attempt snapshot returns `parameters` and `resultSteps`, and the run-case detail
  returns them for the current attempt. App tokens of the new **Playwright** app report like those of
  **JUnit XML**. See [What an entry can carry](#what-an-entry-can-carry) and
  [Run references by name](#run-references-by-name).

- **App tokens report from CI.** An organization app token (`probara_app_…`), created from the
  **JUnit XML** card in **Workspace → Integrations**, belongs to the organization rather than to a
  person and may only make the five calls the Probara CLI makes; everything else, `/mcp` included,
  answers `403 forbidden`. Reporting from CI now needs a paid plan: on Free, a report and the
  creation of an automated run answer `403 forbidden` for every credential. The `api_token` forms of run and plan `author` and of audit `actor` gain `app`, and
  organization attachment items gain `uploaderApp`. See
  [App tokens](https://docs.probara.net/en/developers/authentication/#app-tokens).

- **Runs record their CI source.** `POST /api/v1/projects/{projectId}/runs` and the CI report's
  `run` accept an optional `source: { branch?, commit?, buildUrl? }`; every run response carries
  `source` (`null` when unset). Write-once: `PATCH /api/v1/runs/{runUlid}` does not accept it. See
  [Run source](#run-source).

- **One call records a CI run.** `POST /api/v1/projects/{projectId}/reports` creates or reuses a run,
  matches up to 500 automated results to test cases by display ID or automation key, creates the
  missing cases and records one result per entry, atomically and under `Idempotency-Key`. See
  [Reports](#reports).

- **Organizations nobody opens for 12 months are now scheduled for deletion.** When no member has
  opened an organization in the web app for 12 months, Probara schedules its deletion with the usual
  14-day grace period, so `403 { code: "organization_pending_deletion" }` and a non-null
  `pendingDeletion` on `GET /api/v1/orgs/current` can now appear without an owner or Probara support
  requesting it. Requests with an API key or an OAuth token (MCP included) do not count as activity
  and do not cancel it; a member opening the organization in the web app does. Organizations with a
  paid subscription that will renew are never scheduled this way. See
  [Organizations nobody opens for 12 months](https://docs.probara.net/en/guides/delete-organization/#organizations-nobody-opens-for-12-months).

- **Test runs and milestones have readable IDs.** Every run response now carries `runNumber` and
  `displayId` (`R-12`), and every milestone response `milestoneNumber` and `displayId` (`M-3`).
  Resolve them with `GET /api/v1/projects/{projectId}/runs/by-display-id/{displayId}` and
  `GET /api/v1/projects/{projectId}/milestones/by-display-id/{displayId}` (see
  [Display ID lookups](#display-id-lookups)). Run links in notification emails now point to
  `/projects/{code}/runs/R-{n}`. Additive: ULID endpoints are unchanged.

- **Every attachment write now counts toward the organization storage quota.** The steps writes
  (`PUT /api/v1/test-cases/{caseUlid}/steps`, `POST /api/v1/projects/{projectId}/test-cases` and
  `PATCH /api/v1/test-cases/{caseUlid}` with step attachments), comment create and edit on test cases
  and defects (`POST`/`PATCH .../comments`), run step results
  (`PATCH /api/v1/runs/{runUlid}/cases/{runCaseUlid}/steps/{stepUlid}/result`), clone
  (`POST /api/v1/test-cases/{caseUlid}/clone`, `POST /api/v1/test-cases/bulk-clone`,
  `POST /api/v1/suites/{suiteUlid}/clone`) and `POST /api/v1/projects/{projectId}/avatar` can now answer
  `413 { code: "storage_quota_exceeded" }`, as result and defect attachment uploads already did. A
  refused request changes nothing, with one exception: when two concurrent step or comment saves
  compete for the last free bytes, the one refused may keep its non-file changes (step text, comment
  body, a new case). A request that adds no files, or whose net change frees space, is
  never refused. Hard-deleting a test case or a project, and removing steps, now frees the space of
  their files. The MCP tools that call these endpoints return the same error.

- **Organization ownership now moves by transfer, and an organization has exactly one owner.**
  `POST /api/v1/orgs/{orgUlid}/ownership-transfer` with `{ "userUlid": "…" }` makes another current
  member the owner and the calling owner an `admin` in one step, returning
  `{ previousOwner, newOwner }`. Only the current owner may call it (`403 { code: "forbidden" }`
  otherwise); targeting yourself or a member who already owns the organization is
  `422 { code: "validation_failed" }`, an unknown member is `404`, a concurrent change is
  `409 { code: "conflict" }`, and a `viewer` target needs a free seat
  (`409 { code: "seat_limit_exceeded" }`). The MCP tool `transfer_org_ownership` wraps it.
  **Breaking — `PATCH /api/v1/orgs/{orgUlid}/members/{userUlid}` now refuses `role: "owner"`** with
  `422 { code: "validation_failed" }` for every caller, including the owner; use the transfer instead.

- **Organizations can be scheduled for deletion, and are read-only meanwhile.** An owner schedules the
  deletion with `POST /api/v1/orgs/{orgUlid}/deletion` and cancels it with `DELETE` on the same path,
  both with a session cookie only — no API key or OAuth token can call them. For the 14-day grace
  period, every write to the organization answers the new `403 { code: "organization_pending_deletion" }`,
  API keys and MCP included, and `GET /api/v1/orgs/current` gains `pendingDeletion`
  (`{ requestedAt, scheduledFor }` or `null`). Accepting an invitation to such an organization is
  refused with the same code.

- **An active contract now governs the plan, so self-serve billing answers `409` under it.** While an
  organization holds an active contract, each self-serve billing mutation is refused with
  `409 { code: "conflict", details: { reason: "contract_active" } }` before any call to the billing
  provider: `POST /api/v1/orgs/{orgUlid}/subscription/checkout`,
  `PATCH /api/v1/orgs/{orgUlid}/subscription/seats`,
  `PATCH /api/v1/orgs/{orgUlid}/subscription/plan`,
  `DELETE /api/v1/orgs/{orgUlid}/subscription/pending-change`,
  `POST /api/v1/orgs/{orgUlid}/subscription/trial`,
  `POST /api/v1/orgs/{orgUlid}/subscription/downgrade-to-free`,
  `POST /api/v1/orgs/{orgUlid}/subscription/cancel` and
  `DELETE /api/v1/orgs/{orgUlid}/subscription/cancel`. The reads and the customer portal
  (`POST /api/v1/orgs/{orgUlid}/subscription/portal`) stay available. The subscription readout
  gains `managedPlan`, `{ label, tierKey, committedSeats, endsAt }`, while a contract governs the
  organization. `label` is `"enterprise"` or `"managed"`, and `managedPlan` is `null` otherwise. The
  catalog lists only the published tiers; checkout or a plan change to any other tier answers
  `422 { code: "validation_failed" }`.

- **A scheduled subscription change can now be cancelled through the API.**
  `DELETE /api/v1/orgs/{orgUlid}/subscription/pending-change` discards a change scheduled to take
  effect at the period end (a seat reduction, or a plan move deferred to the renewal) and leaves
  the current commitment in force, returning the same subscription readout every other mutation on
  this surface returns. When nothing is scheduled it answers `409 { code: "conflict" }` without
  sending any change to the billing provider. Requires `org-settings.manage`.

- **Seat commitments below current usage are now accepted, and the dedicated refusal is retired.**
  **Breaking — the `409 { code: "seat_commitment_below_usage" }` refusal is removed** from
  `POST /api/v1/orgs/{orgUlid}/subscription/checkout` and
  `PATCH /api/v1/orgs/{orgUlid}/subscription/seats`. Any positive seat commitment is now accepted;
  when the committed seats end up below the organization's used seats, Probara demotes the newest
  non-owner members (pending invitations first) to the free `viewer` role instead of refusing the
  change.

- **Test-run settings are now readable and configurable, at project and organization level.**
  `GET`/`PATCH /api/v1/projects/{projectId}/run-settings` and
  `GET`/`PATCH /api/v1/orgs/{orgUlid}/run-settings` expose twelve settings governing marking,
  closed-run writes, and run-creation defaults. Eleven resolve through
  `project → organization → code default`; `defaultAssigneeUserId` is project-only and has no
  organization tier. Three related behavior changes ship alongside: (1) a closed, non-aborted run
  now accepts result-notes edits and result-attachment stage/commit/delete by default
  (`allowResultsInClosedRuns` defaults `true`) — an organization that wants the previous, stricter
  behavior sets it to `false`; (2) marking a run case now rejects with
  `403 { code: "run_case_assignee_locked" }` when the project's `assigneeResultLock` setting is on
  and the caller is neither the assignee nor exempt, with **no exemption for API tokens**; (3)
  marking a run case with a terminal status now rejects with `422 validation_failed`
  (`details.code: "elapsed_ms_required"`) when the project's `timeTracking` setting is `required`
  and the request omits `elapsedMs` — bulk mark and bulk submit-result are exempt from this last
  rejection. See [Test-run settings](https://docs.probara.net/en/developers/api/project-run-settings/) and
  [Run cases](https://docs.probara.net/en/developers/api/run-cases/).

- **Invitations can now be created in bulk, up to 50 addresses per request.**
  `POST /api/v1/orgs/{orgUlid}/invitations/bulk` accepts
  `{ emails: string[], role, locale?, grantAllPrivateProjects? }` — one role and one locale apply
  to the WHOLE list, never per address. `emails` accepts at most 50 entries; a duplicate address
  in the same list is evaluated once, in first-appearance order. The response is always
  `{ results: [{ email, status }] }`, one entry per distinct submitted address, classified into
  `created`, `duplicate_pending`, `already_member`, or `invalid_email`. The organization-wide
  pending-invitation ceiling and the seat limit are each evaluated once against the count of
  addresses that would actually be created, never per address, and can still refuse the whole
  request with the same `409` conflict/seat-limit errors the single-invitation endpoint returns.
  `grantAllPrivateProjects` (default `false`; also accepted on the single-invitation endpoint) is a
  **private-project access intent**, not an immediate grant: when `true`, the invitee is granted a
  `granted` row on every private project **at the moment they accept**, replayed asynchronously
  from the invitation's own stored intent — never evaluated at invite time. It is a no-op for the
  `owner`/`admin` roles, since those roles already bypass the private-project allowlist and would
  gain nothing from an explicit grant. The web app's invite dialog surfaces this as the **Access**
  checkbox on both the Single and Bulk tabs: present and **disabled with a stated reason** whenever
  the selected role already bypasses the allowlist, so the control is never hidden — only inert
  where granting it would do nothing.

- **An invitation's terminal email delivery outcome is now readable, and the dedicated
  suppressed-recipient error code is retired.** `GET /api/v1/orgs/{orgUlid}/invitations` items
  gained `emailDeliveryStatus`: `null` while the invitation email is still queued or was
  delivered, `"suppressed"` if the recipient address is permanently undeliverable, or `"failed"`
  if delivery was retried until the outbox's retry ceiling. This is a read model only — invitation
  email is delivered from an internal outbox after the response, so no invitation endpoint has a
  delivery verdict to report while its request is open. **Breaking — the dedicated
  `409 { code: "email_recipient_suppressed" }` conflict is removed** from both invitation create
  and resend; a permanently undeliverable recipient no longer surfaces as a distinct error code on
  either endpoint — both now answer their existing tolerant success status (`201`/`200`) on every
  send outcome, and the outcome is reported later through `emailDeliveryStatus` instead.

- **A group's project assignments can now be added in bulk, on the group plane.**
  `POST /api/v1/groups/{userGroupUlid}/projects` assigns one or many projects to a group in a
  single request (`{ projectUlids: string[] }`, up to 200, `.min(1)`) — a new operation alongside
  the existing project-plane group allowlist endpoints above, not a replacement for them. It
  carries **no organization role gate of its own**: authorization runs per project, inside the
  same transaction, using the identical rule `POST /api/v1/projects/{projectId}/access/groups`
  already enforces. The write is all-or-nothing — an unresolvable (`404`) or unauthorized (`403`)
  project anywhere in the batch rejects the entire request and commits nothing, including a
  project earlier in the batch the caller **was** authorized for. Repeated ULIDs deduplicate to
  one assignment; an already-assigned project is never an error and never blocks the rest of the
  batch. See [User groups](https://docs.probara.net/en/developers/api/user-groups/).

- **Group members can now be added in bulk, in one request.**
  `POST /api/v1/groups/{userGroupUlid}/members` now also accepts `{ userUlids: string[] }`
  alongside the existing `{ userUlid: string }` form — the same operation and path, not a new
  endpoint. A body carrying both keys, or neither, is rejected `422 { code: "validation_failed" }`.
  The write is all-or-nothing: a departed (or never-a-member) `userUlid` anywhere in the array
  rejects the **entire** request with `404 not_found` and adds nobody, and a request that would add
  zero new memberships (every listed user already a member) returns `409 { code: "conflict" }` —
  matching the existing single-member behavior at cardinality one. Repeated ULIDs are silently
  deduplicated. Each newly added member still emits its own `user_group.member_added` event; a
  filtered (already-member) ULID emits none. See [User groups](https://docs.probara.net/en/developers/api/user-groups/).

- **A group's initial project assignments can now be seeded atomically at creation.**
  `POST /api/v1/groups` accepts an optional `projectUlids` array, following the same convention as
  the existing `memberUserUlids`: the group row and every seeded assignment commit or fail together,
  duplicates are silently deduplicated, and an unknown project ULID rejects the entire create with
  `404 not_found`. A seeded assignment is never refused for targeting a public project. Neither field
  is accepted on `PATCH /api/v1/groups/{userGroupUlid}` — an unknown key rejects the whole request.
  See [User groups](https://docs.probara.net/en/developers/api/user-groups/).

- **User groups can now be assigned to a project's access allowlist.**
  `GET`/`POST /api/v1/projects/{projectId}/access/groups` and
  `DELETE /api/v1/projects/{projectId}/access/groups/{userGroupUlid}` mirror the individual
  member allowlist's plane and authorization rule, and `GET /api/v1/groups/{userGroupUlid}/projects`
  reports the projects a group is assigned to, filtered to what the calling member can otherwise
  discover. `projectCount` on `GET/POST /api/v1/groups` and `GET /api/v1/groups/{userGroupUlid}`
  is no longer always `0` — it now reports the group's real assignment count. See
  [User groups](https://docs.probara.net/en/developers/api/user-groups/) and [Project access](https://docs.probara.net/en/developers/api/project-access/).

- **An organization-scoped seats readout is now available.**
  `GET /api/v1/orgs/{orgUlid}/seat-usage` reports the organization's used seats and its
  effective seat limit — the same used-seat definition the seat-limit enforcement compares,
  never a recomputed or roster-derived count. Available to any authenticated member of the
  organization, with no role gate. Reading it never mutates anything.

- **An organization audit log export endpoint is now available.**
  `GET /api/v1/orgs/{orgUlid}/audit-events/export` downloads the organization's audit log as
  CSV for a required, bounded window (max 366 days). Gated on organization role (`owner`/`admin`)
  AND the `auditExportEnabled` entitlement — both refusals are `403 forbidden` and
  indistinguishable on the wire. An exact count is taken before any row is read; a window over
  25,000 events is refused `422 validation_failed` naming both the actual count and the ceiling.
  Rate-limited to 10 exports per organization per hour, independently of the test-case export
  rate limit. The export never mutates or deletes anything.

- **An organization-scoped API result usage readout is now available.**
  `GET /api/v1/orgs/{orgUlid}/api-result-usage` reports the organization's counted
  machine-authored results for the current calendar month alongside its effective
  `apiResultsPerMonth` ceiling. Available to any authenticated member of the organization,
  with no role gate. It reports the authoritative counter's own value — never a count
  recomputed by scanning results — and reading it never creates, resets or advances the
  counter. Once the calendar month turns, the readout reports `0` for the new period with no
  operator action or deploy.

- **Organization monthly API result limit is now enforced.** Marking a run case
  (`PATCH /api/v1/runs/{runUlid}/cases/{runCaseUlid}` with a `status`), `bulk-mark`, and
  `bulk-submit-result` now reject a request that would push the organization's counted
  machine-authored results for the current calendar month past its effective
  `apiResultsPerMonth` with `409 { code: "api_result_limit_exceeded" }`. Only a request
  authenticated with an API token or an OAuth access token is counted; a session-cookie caller
  (including the product's own web app) is never metered or refused on this ground. A bulk
  request is refused **whole** — it never partially applies. The refusal carries no
  `Retry-After` header and reveals neither your ceiling nor your current usage; it is distinct
  from `429 too_many_requests` (a burst limiter) and from the existing `409 conflict` a closed
  run already returns. The documented default is the Free tier's monthly ceiling (5,000 counted
  results); an integration under that volume observes no change, and a higher-tier plan or an
  explicit override raises the ceiling for a specific organization, with no deploy, and the
  ceiling resets automatically at the start of the next calendar month.

- **Organization API plan rate limit is now enforced.** Any `/api/v1` request authenticated with an API token or an OAuth access token now counts against your organization's per-minute plan ceiling. Exceeding it responds `429 { code: "too_many_requests" }` with a `Retry-After` header holding the whole seconds remaining until the current 60-second window rolls — wait that long and the identical request is admitted, no retry backoff math required beyond that. This ceiling applies **only** to machine-authenticated traffic: a session-cookie caller (including the product's own web app) is never refused on this ground. The documented default is 60 requests per minute; a higher-tier plan or an explicit override raises the ceiling for a specific organization, unblocking the identical previously-refused request immediately, with no deploy. A `/mcp` `tools/call` that dispatches one or more `/api/v1` sub-requests counts each dispatched sub-request separately from — and in addition to — the existing `/mcp` burst limits, which remain unchanged.

- **The project limit now reaches projects an organization already holds.** An organization over
  its effective project limit keeps only its most recently created projects open; every older one
  is locked, and every read and write against a locked project answers
  `403 { code: "project_locked" }`, over `/api/v1` and over MCP, for a session and for an API key
  alike. `DELETE /api/v1/projects/{projectId}` still succeeds — it is the only operation a locked
  project serves, and deleting one reopens the next-oldest as soon as the total is back inside the
  allowance. Nothing is deleted by the lock: `GET /api/v1/projects` still returns every project,
  each carrying `lockedAt` (epoch milliseconds, or `null`), and raising the allowance clears every
  stamp and reopens the projects unchanged. A public run share link belonging to a locked project
  collapses into the same byte-identical `404 { code: "not_found" }` an unknown token earns, so a
  valid link cannot be used to detect the state. This supersedes the create-only behaviour
  described in the entry below.
- **Organization project limit is now enforced.** `POST /api/v1/projects` now rejects a request that would push the organization's used projects past its effective project limit with `409 { code: "project_limit_exceeded" }`, distinct from the `409 { code: "conflict" }` the same endpoint already returns for a duplicate project id. The documented default is the Free tier's project ceiling (3); a higher-tier plan or an explicit override raises the limit and unblocks the identical previously-refused request with no deploy. At the time, no existing project was affected by a lowered limit and only the next create was
  refused; that is no longer true — see the project-lock entry above.

- **Inviting an address that is already a member is now refused.** `POST /api/v1/orgs/{orgUlid}/invitations` now returns `409 { code: "conflict" }` when the invited address already holds a membership in that organization, for **every** role including `viewer`. The correct operation for somebody who is already inside the organization is a role change (`PATCH /api/v1/orgs/{orgUlid}/members/{userUlid}`), not an invitation. Previously only a second **pending** invitation for the same address was refused, so an address whose earlier invitation had already been accepted could be invited again — and while that invitation stayed pending, that person counted twice against the organization's seat limit. The check is scoped to the inviting organization: an address that is a member of a _different_ organization is still invitable.

- **Organization seat limit is now enforced.** `POST /api/v1/orgs/{orgUlid}/invitations` (for a non-`viewer` role) and `PATCH /api/v1/orgs/{orgUlid}/members/{userUlid}` (when promoting a `viewer` to a non-`viewer` role) now reject a request that would push the organization's used seats past its effective seat limit with `409 { code: "seat_limit_exceeded" }`. A `viewer` role never consumes a seat and is never refused. Accepting an outstanding invitation is never refused on seat grounds — it only ever converts a reservation already counted.

- **Potentially breaking — Test case `PATCH` now enforces the write-role gate server-side.** `PATCH /api/v1/test-cases/{caseUlid}` (and `PATCH /defects/{defectUlid}`) now reject a `viewer`-role caller with `403 forbidden` before any write, matching the gate already applied to custom field values and other write endpoints. A `viewer`-scoped API key that previously succeeded on these two routes now receives `403`.

- **Breaking — Runs auto-close; Reopen removed; `rerun-failed` generalized into `clone`.** A run now closes itself automatically the instant every case carries a result (no client call needed); the latch is one-way — nothing done inside a closed run ever reopens it. `POST /api/v1/runs/{runUlid}/reopen` is **removed** (`404`). `POST /api/v1/projects/{projectUlid}/runs/{runUlid}/rerun-failed` is renamed to `.../clone` and generalized: body is now `{ title: string, cloneAssignees?: boolean, statusFilter?: TestOutcomeStatus[] }` (previously `{ defaultAssigneeUlid? }`, which only cloned failed/blocked/untested/retested cases). A closed, non-aborted run now accepts `PATCH /runs/{runUlid}`, case add/remove/mark/retry, and reconcile — only an **aborted** run stays terminal-frozen (`409 conflict`).

- **Runs — structured environment linkage.** `POST /api/v1/projects/{projectUlid}/runs` and `PATCH /api/v1/runs/{runUlid}` now accept `environmentId` (ULID). Run responses expose a nested `environment: { ulid, name, slug } | null` object and `environmentId`. The legacy free-text `environment` string is still accepted on create for back-compat but should not be used in new integrations. `GET .../runs/environments` is retained but deprecated; filter by `env=<environmentUlid>` instead. See [Environments API](https://docs.probara.net/en/developers/api/environments/).

- **Breaking — Results ingestion is now an append-only log.** `PUT /api/v1/runs/{runUlid}/results/{caseUlid}` (idempotent upsert) is **removed**. Record outcomes by marking run cases (`PATCH /api/v1/runs/{runUlid}/cases/{runCaseUlid}`) or steps; `GET /api/v1/runs/{runUlid}/results` now returns the full marking history (multiple rows per case) with `executedByUlid`. Runs gained `state` (`open`/`closed`), `description`, `defaultAssigneeUlid`, and progress counters.

- **Projects — `PATCH` / `DELETE` restricted to owner/admin.** `member` and `viewer` now receive `403 forbidden` when mutating project metadata. Use an owner or admin token or session to rename the project code or delete a project.

- **Breaking — Test case classification fields moved to custom-field-values.** `POST`/`PATCH` on `/api/v1/projects/{projectId}/test-cases` and `/api/v1/test-cases/{caseUlid}` no longer accept `priority`, `severity`, `status`, `type`, `layer`, `behavior`, `isFlaky`, `preconditions`, or `postconditions`. Read them from the `customFieldValues` array on the case response; write them via `PUT /api/v1/test-cases/{caseUlid}/custom-field-values`. The matching enum values continue to be the seeded options on the corresponding system custom fields.
